git-integrate: Bring the Branches Back Home

August 28th, 2015

git-integrate: Bring the Branches Back Home

If lord Byron, or more aptly his daughter, would look how loosely I adhere to processes, I’m sure either one would agree that while I’m not untrue, I’m sure fickle as heck.

It’s not that I don’t see their values. Not at all. The problem rather lies in remembering to apply all the steps. Which is why I love so much; I set the process once, and then I let the software deal with the tedious tasks of remembering, checking and applying all the required steps each time I type dzil release.

In the same frame of mind, I have quite a few git shortcuts lying around. Most are simple aliases, others are slightly more complex scripts. Like this last entry in my stable: git-integrate.

The goal git-integrate is to help out merging back feature and bug branches back into master. The script, as it is now, is quite heavily tailored to my needs (Perl projects, mostly dzil-powered), but as you will see the script can be easily modified to fit pretty much anyone’s integration process. Let me show you.

Getting out the toolbox

As per usual git magic, the script itself will be called git-integrate, such that invoking

$ git integrate

on the command line will call it. All the cli app shenanigans will be dealt by and the Git repository interaction by .

package Git::Integrate;

use MooseX::App::Simple;
use MooseX::MungeHas 'is_ro';

use Git::Repository 'AUTOLOAD';

use experimental 'signatures';

has git => (
    is => 'lazy',
    default => sub {
        Git::Repository->new( work_tree => '.' );
    },
    handles => { git_run => 'run' },
);

has target => (
    default => 'master',
    documentation => 'branch merged into',
);

# branch to merge
has branch => sub ($self) {
    $self->git->branch =~ /^* (S+)/m; $1;
};

Is everything in order?

Eventually, the script will merge the feature branch into the target branch, but before doing so, there is a couple of things I want to check.

First, is everything properly commited, or is my work tree all dirty with stuff lying around?

before run => sub($self) {
    my $status = $self->git->status( '--porcelain' )
        or return;

    die "some files are not commited:\n$status\n";
};

Is the feature branch rebased on top of the target branch? (I like my branches all rebased and neat)

before run => sub($self) {
    die "not rebased on top of ", $self->target unless
        grep { s/^* //r eq $self->branch } $self->git->branch( '--contains', $self->target );
};

Was I a good boy, and did I add tests for the new code?

option notestcheck => (
    is      => 'ro',
    isa     => 'Bool',
    default => 0,
);

before run => sub($self) {
    return if $self->notestcheck;

    my @tests = $self->git->diff( '--name-only', $self->target, $self->branch, 't' )
        or die "no new tests\n";

    say join "\n\t", "new tests:", @tests;
};

And are those tests, and all the other ones, passing?

option notest => (
    is      => 'ro',
    isa     => 'Bool',
    default => 0,
);

before run => sub($self) {
    return if $self->notest;

    die "tests are failing\n" if system 'prove', '-l', 't'; 
};

If so, hey, things look good. Let’s merge!

Merge, baby, merge

When merging back to the master branch, I want to edit the changelog with the new additions. Since I’m rather scatterbrain, it’d be nice to have a view of all the commit messages of the feature branch to jog my memory.

use Path::Tiny;

sub run ($self) {
    
    # prepare the log message
    my $log = $self->git->log( '--pretty=full', $self->target . '..' );

    my $msg_file = path( 'git-integrate-msg' );
    $msg_file->spew( $log );

    system 'vim', '-o', $msg_file, 'Changes';

    $msg_file->remove;

And since we now have some description in the changelog of what we did in the merged branch, why not use it as the merge message?

    $self->git->commit( '-m', 'changelog', 'Changes' )
        if $self->git->diff( 'Changes' );

    my $msg = "merge branch '" . $self->branch . "'\n\n" 
            . join( "\n", $self->git->diff( $self->target . '..', 'Changes' ) =~ /^(?:[+-] )(.*?)$/mg )
            . "\n\n"; 

And let’s add some information that GitHub can use to auto-close the related tickets.

    $msg .= join ', ', map { "Fixes #$_" } $msg =~ /GH#(d+)/g; 

And with that done, the only have one last step: jump on the master branch and merge things proper.

    $self->git->checkout( $self->target );

    $self->git->merge( '--no-ff', '-m', $msg, $self->branch );

    say "merged!";
}

Aaaand we’re done.

The Full Script

#!/usr/bin/env perl

package Git::Integrate;

use 5.20.0;

use MooseX::App::Simple;
use MooseX::MungeHas 'is_ro';

use Git::Repository 'AUTOLOAD';
use Path::Tiny;

use experimental 'signatures';

option notest => (
    is      => 'ro',
    isa     => 'Bool',
    default => 0,
);

option notestcheck => (
    is      => 'ro',
    isa     => 'Bool',
    default => 0,
);

has git => (
    is => 'lazy',
    default => sub {
        Git::Repository->new( work_tree => '.' );
    },
    handles => { git_run => 'run' },
);

has target => (
    default => 'master',
);

has branch => sub ($self) {
    $self->git->branch =~ /^* (S+)/m; $1;
};

before run => sub($self) {
    # first check if all is neatly commited

    my $status = $self->git->status( '--porcelain' )
        or return;

    die "some files are not commited:\n$status\n";
};

before run => sub($self) {
    # are we on top of the target branch?
    die "not rebased on top of ", $self->target unless
        grep { s/^* //r eq $self->branch } $self->git->branch( '--contains', $self->target );
};

before run => sub($self) {
    return if $self->notestcheck;

    my @tests = $self->git->diff( '--name-only', $self->target, $self->branch, 't' )
        or die "no new tests\n";

    say join "\n\t", "new tests:", @tests;
};

before run => sub($self) {
    return if $self->notest;

    die "tests are failing\n" if system 'prove', '-l', 't'; 
};

sub run ($self) {
    
    # prepare the log message
    my $log = $self->git->log( '--pretty=full', $self->target . '..' );

    my $msg_file = path( 'git-integrate-msg' );
    $msg_file->spew( $log );

    system 'vim', '-o', $msg_file, 'Changes';

    $msg_file->remove;

    $self->git->commit( '-m', 'changelog', 'Changes' )
        if $self->git->diff( 'Changes' );

    my $msg = "merge branch '" . $self->branch . "'\n\n" 
            . join( "\n", $self->git->diff( $self->target . '..', 'Changes' ) =~ /^(?:[+-] )(.*?)$/mg )
            . "\n\n"; 

    $msg .= join ', ', map { "Fixes #$_" } $msg =~ /GH#(d+)/g; 

    $self->git->checkout( $self->target );

    $self->git->merge( '--no-ff', '-m', $msg, $self->branch );

    say "merged!";

}

__PACKAGE__->meta->make_immutable;

__PACKAGE__->new_with_options->run;