{"thread":{"id":"53757","subject":"[RFC] Perl script git-squash-merge to combine merge commits","startedAt":"2020-06-25T21:33:21Z","lastAt":"2020-06-25T21:33:21Z","messageCount":1,"participants":["Jacob Bachmeyer"],"isPatch":false,"patchVersion":null,"patchTotal":null},"messages":[{"id":"400646","messageId":"5EF5181B.6060208@gmail.com","threadId":"53757","inReplyTo":null,"subject":"[RFC] Perl script git-squash-merge to combine merge commits","fromName":"Jacob Bachmeyer","fromEmail":"jcb62281@gmail.com","sentAt":"2020-06-25T21:33:15Z","receivedAt":"2020-06-25T21:33:21Z","isPatch":false,"sender":{"key":"jcb62281@gmail.com","avatar":null},"body":"I have written a small Perl script, included below under the same terms \nas Perl itself, that combines a group of stepwise 2-input merge commits \ninto a single N-input merge commit, with the long explanation in the \nincluded POD.\n\n-- Jacob\n\n--- 8< ---\n\n#!/usr/bin/perl\n# -*- CPerl -*-\n\nuse strict;\nuse warnings;\n\nuse File::Spec;\nuse Getopt::Long;\nuse Pod::Usage;\n\nuse IPC::Open2;\n\nmy $Help = 0;\nmy $Verbose = 0;\n\nGetOptions('help'\t=> \\$Help,\n\t   'verbose|v'\t=> \\$Verbose) or pod2usage(2);\npod2usage(1) if $Help;\n\nmy $GIT_DIR = `git rev-parse --git-dir`; chomp $GIT_DIR;\ndie \"could not find Git directory\" unless $GIT_DIR;\n\n# read a commit; return hashref with keys:\n#  id\t\t-- Git commit id\n#  tree\t\t-- tree object id for this commit\n#  parents\t-- array of parents of this commit\n#  author\t-- commit author line (name, timestamp)\n#  committer\t-- commit committer line (name, timestamp)\n#  files\t-- filenames mentioned in commit diff\nsub read_commit ($) {\n  my $commit_id = shift;\n\n  my $commit = {};\n\n  open my $git_show, '-|', qw/git show --format=raw/, $commit_id\n    or die \"git show $commit_id: $!\";\n  while (<$git_show>) {\n    chomp;\n    my @fields = split /[[:space:]]+/, $_;\n    if (@fields) {\n      if ($fields[0] eq 'parent') {\n\tpush @{$commit->{parents}}, $fields[1];\n      } elsif ($fields[0] eq 'diff' && $fields[1] eq '--cc') {\n\tpush @{$commit->{files}}, $fields[2];\n      } elsif ($fields[0] eq 'tree') {\n\tdie \"extra tree in commit $commit_id\" if $commit->{tree};\n\t$commit->{tree} = $fields[1];\n      } elsif ($fields[0] eq 'commit') {\n\tdie \"extra commit id in commit $commit_id\" if $commit->{id};\n\t$commit->{id} = $fields[1];\n      }\n    }\n  }\n  close $git_show;\n\n  return $commit\n}\n\n# commits indexed by id\nmy %Commits = ();\n\nsub intern_commit ($) {\n  my $id = shift;\n  return $Commits{$id} if $Commits{$id};\n  my $commit = read_commit $id;\n  $Commits{$id} = $Commits{$commit->{id}} = $commit;\n  return $commit;\n}\n\n# branch names indexed by id\nmy %Branches = ();\n# [id, branch name] pairs\nmy @Branches = ();\n\n#\n# Read available branches\n#\n{\n  open my $git_refs, '-|', qw/git show-ref --heads/;\n  while (<$git_refs>) {\n    chomp;\n    if (m[^([[:xdigit:]]{40,})[[:space:]]+refs/heads/(.*)$])\n      { push @Branches, [$1, $2] }\n    else { die \"could not parse ref line: $_\" }\n  }\n  close $git_refs;\n\n  foreach my $cell (@Branches)\n    { $Branches{$cell->[0]} = $cell->[1]\n\tunless $cell->[1] =~ m/^local/ && $Branches{$cell->[0]} }\n}\n\n# tip of merge tree (set once at runtime)\nmy $STARTING_COMMIT = intern_commit('HEAD');\n\n# tree object id of merged tree (set once at runtime)\nmy $FINAL_TREE = $STARTING_COMMIT->{tree};\n\n# array of branches involved in merge tree\nmy @Merged_Branches = ();\n\n# hash of conflicts resolved in merge tree; file name => count\nmy %Conflicts_Resolved = ();\n\n#\n# Read needed commits\n#\n{\n  # array of commit ids to read\n  my @Queue = ($STARTING_COMMIT->{id});\n\n  while (@Queue) {\n    my $id = shift @Queue;\n    my $commit = intern_commit $id;\n\n    if ($Verbose) {\n      print \"read commit $commit->{id}\";\n    }\n\n    if (scalar @{$commit->{parents}} == 2) {\n      # commit is a simple 2-input merge\n      if ($Verbose) {\n\tprint \"; merge; resolving conflicts:\\n\";\n\tprint \"    $_\\n\" for @{$commit->{files}};\n      }\n      unshift @Merged_Branches, grep $Branches{$_}, @{$commit->{parents}};\n      push @Queue, grep !$Branches{$_}, @{$commit->{parents}};\n    } else {\n      # commit was an input to the merge tree, but not a branch tip\n      if ($Verbose) {\n\tprint \"; surprise merge input\\n\";\n      }\n      unshift @Merged_Branches, $commit->{id};\n    }\n\n    $Conflicts_Resolved{$_}++ for @{$commit->{files}};\n  }\n}\n\n#\n# Prepare new commit message\n#\nmy $Commit_Message_File = File::Spec->catfile($GIT_DIR, 'COMMIT_EDITMSG');\nopen EDITMSG, '>', $Commit_Message_File\n  or die \"cannot open $Commit_Message_File for write: $!\";\n\nprint EDITMSG 'Merge ',(scalar @Merged_Branches), \" branches\\n\\n\";\nfor my $tip_id (@Merged_Branches) {\n  print EDITMSG '* ', $Branches{$tip_id}, \":\\n\",\n    '   merged at commit ', $tip_id, \"\\n\";\n}\n\nprint EDITMSG <<'END';\n\n# Please edit the commit message for the combined merge commit.  Lines\n# starting with '#' will be ignored, along with initial or trailing blank\n# lines.  Lines containing only whitespace will become empty.  An empty\n# message aborts the commit.\nEND\n\nclose EDITMSG;\n\n#\n# Give the user a change to edit the message or abort\n#\n{\n  my $EDITOR = `git var GIT_EDITOR`; chomp $EDITOR;\n\n  system \"$EDITOR $Commit_Message_File\";\n\n  open EDITMSG, '<', $Commit_Message_File\n    or die \"cannot open $Commit_Message_File for read: $!\";\n  my $linecnt = 0;\n  while (<EDITMSG>)\n    { next if m/^#/ || m/^[[:space:]]*$/; $linecnt++ }\n  close EDITMSG;\n\n  if ($linecnt == 0) {\n    print \"commit aborted due to empty message\\n\";\n    exit 0;\n  }\n}\n\n#\n# Write the new commit\n#\n\n# new commit id\nmy $Merge_Commit_ID = undef;\n\n{\n  my $get_commit_id; my $put_commit_msg;\n\n  open my $commit_msg, '<', $Commit_Message_File\n    or die \"cannot open $Commit_Message_File for read: $!\";\n  my $collapse_blanks = 1; my $prev_blank = '';\n  my $pid =\n    open2($get_commit_id, $put_commit_msg,\n\t  qw/git commit-tree/, $FINAL_TREE, map {+'-p' => $_} @Merged_Branches);\n  while (<$commit_msg>) {\n    next if m/^#/;\n    next if $collapse_blanks && m/^[[:space:]]*$/;\n    if ($prev_blank) { print $put_commit_msg $prev_blank; $prev_blank = '' }\n    $prev_blank = \"\\n\" if s/^[[:space:]]*$//;\n    print $put_commit_msg $_;\n    $collapse_blanks = m/^[[:space:]]*$/;\n  }\n  close $commit_msg;\n  close $put_commit_msg;\n\n  $Merge_Commit_ID = <$get_commit_id>; chomp $Merge_Commit_ID;\n  die \"could not get merge commit ID\" unless $Merge_Commit_ID;\n}\n\n#\n# Fail if Git reports conflicts in the combined merge that had not been\n# resolved in the previous merges.\n#\n{\n  my $merge_commit = read_commit $Merge_Commit_ID;\n  my @new_conflicts = ();\n\n  foreach my $file (@{$merge_commit->{files}}) {\n    push @new_conflicts, $file unless $Conflicts_Resolved{$file};\n  }\n\n  if (@new_conflicts) {\n    print <<'END';\nERROR:  False conflict introduced in combined merge!\n\nThe following files were not listed as conflicts in any input merge commit,\n but Git reports a conflict resolved in the combined merge:\nEND\n\n    print '    ', $_, \"\\n\" for sort @new_conflicts;\n\n    print \"\\ncombined merge failed with commit $Merge_Commit_ID\\n\";\n  } else {\n    # The combined merge looks good.  Save it.\n    system {'git'} qw/git update-ref HEAD/, $Merge_Commit_ID;\n  }\n}\n\n__END__\n\n=head1 NAME\n\n    git-squash-merge - combine merges into one merge commit\n\n=head1 SYNOPSIS\n\n    git-squash-merge [options]\n\n=head1 DESCRIPTION\n\nRewrites history to collect a combined merge after a series of manual\nmerges.  This command does B<not> produce a new tree or change the contents\nof the tree in any way; it only creates an octopus merge commmit to carry\nthe results of a group of 2-input merges.\n\nAssume that development has diverged in several directions, all of which\nmerge readily, but administrivia such as ChangeLog entries or Emacs\nLocalWords lists in documentation present merge conflicts and prevent a\ndirect octopus merge.\n\n     B---D---F topic1\n    /\n   +---C---E topic2\n  /\n A master\n  \\\n   +---H---K topic3\n    \\\n     G---I---J topic4\n\nAll of these branches should be merged into master, but a direct octopus\nmerge fails because they all have different ChangeLog updates.  So they\nmust be merged one at a time, resolving the conflicts manually along the\nway.\n\n     B---D----F topic1\n    /\t       \\\n   +---C---E    \\ topic2\n  /\t    \\    \\\n A           L---MN master\n  \\\t    /   /\n   +---H---K   / topic3\n    \\\t      /\n     G---I---J topic4\n\nNow master has a correct merged tree (and testing has validated that the\nmerged result is good) but history is a bit of a mess.  This tool replaces\nthe merge commits LMN with single octopus merge O using the tree object\nfrom commit N.\n\n     B---D---F topic1\n    /\t      \\\n   +---C---E---+ topic2\n  /\t\t\\\n A               O master\n  \\\t\t/\n   +---H---K---+ topic3\n    \\\t      /\n     G---I---J topic4\n\nBecause the tree from commit N is reused for commit O, commit O contains\nthe end product of the manual stepwise merge reconciliation, but is a\nsingle commit with all four branches as its parents.\n\n=head1 OPTIONS\n\n=over\n\n=item B<-v>, B<--verbose>\n\nEmit messages as the merge tree is traced.\n\n=back\n\n=head1 SEE ALSO\n\ngit-merge(1)\n\ngit-commit-tree(1), git-rev-parse(1), git-show(1),\ngit-show-ref(1), git-update-ref(1), git-var(1)\n\n=head1 AUTHOR\n\nWritten by Jacob Bachmeyer <jcb62281+dev@gmail.com>\n\n=head1 COPYRIGHT AND LICENSE\n\nCopyright (C) 2020 by Jacob Bachmeyer\n\nThis tool is free software; you can redistribute it and/or modify\nit under the same terms as Perl itself.\n\n\n"}]}