unixorn/git-extra-commands

View on GitHub
bin/git-forest

Summary

Maintainability
Test Coverage
#!/usr/bin/env perl
#
#    git-森林
#    text-based tree visualisation
#    Copyright © Jan Engelhardt <jengelh [at] medozas de>, 2008
#
#    This program is free software; you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation; either version 2 or 3 of the license.
#
use Getopt::Long;
use Git;
use strict;
use utf8;
use Encode qw(encode);
my $Repo          = Git->repository($ENV{"GIT_DIR"} || ".");
my $Pretty_fmt    = "format:%s";
my $Reverse_order = 0;
my $Show_all      = 0;
my $Show_rebase   = 1;
my $Style         = 1;
my $Subvine_depth = 2;
my $With_sha      = 0;
my %Color         = (
    "default" => "\e[0m", # ]
    "at"      => "\e[1;30m", # ]
    "hhead"   => "\e[1;31m", # ]
    "head"    => "\e[1;32m", # ]
    "ref"     => "\e[1;34m", # ]
    "remote"  => "\e[1;35m", # ]
    "sha"     => "\e[0;31m", # ]
    "tag"     => "\e[1;33m", # ]
    "tree"    => "\e[0;33m", # ]
);

&main();

sub main
{
    &Getopt::Long::Configure(qw(bundling pass_through));
    &GetOptions(
        "all"       => \$Show_all,
        "no-color"  => sub { %Color = (); },
        "no-rebase" => sub { $Show_rebase = 0; },
        "a"         => sub { $Pretty_fmt = "format:\e[1;30m(\e[0;32m%an\e[1;30m)\e[0m %s"; }, # ]]]]
        "pretty=s"  => \$Pretty_fmt,
        "reverse"   => \$Reverse_order,
        "svdepth=i" => \$Subvine_depth,
        "style=i"   => \$Style,
        "sha"       => \$With_sha,
    );
    ++$Subvine_depth;
    if (substr($Pretty_fmt, 0, 7) ne "format:") {
        die "If you use --pretty, it must be in the form of --pretty=format:";
    }
    $Pretty_fmt = substr($Pretty_fmt, 7);
    while ($Pretty_fmt =~ /\%./g) {
        if ($& eq "\%b" || $& eq "\%n" || ($&.$') =~ /^\%x0a/i) {
            die "Cannot use \%b, \%n or \%x0a in --pretty=format:";
        }
    }
    if ($Show_all) {
        #
        # Give --all back. And add HEAD to include commits
        # in the rev list that belong to a detached HEAD.
        #
        unshift(@ARGV, "--all", "HEAD");
    }
    if ($Reverse_order) {
        tie(*STDOUT, "ReverseOutput");
    }
    &process();
    if ($Reverse_order) {
        untie *STDOUT;
    }
}

sub get_line_block
{
    my($fh, $max) = @_;

    while (scalar(@h::ist) < $max) {
        my $x;

        $x = <$fh>;
        if (!defined($x)) {
            last;
        }
        push(@h::ist, $x);
    }

    my @ret = (shift @h::ist);
    foreach (2..$max) {
        push(@ret, $h::ist[$_-2]);
    }
    return @ret;
}

sub process
{
    my(@vine);
    my $refs = &get_refs();
    my($fh, $fhc) = $Repo->command_output_pipe("log", "--date-order",
                    "--pretty=format:<%H><%h><%P>$Pretty_fmt", @ARGV);

    while (my($line, @next_sha) = get_line_block($fh, $Subvine_depth)) {
        if (!defined($line)) {
            last;
        }
        chomp $line;
        my($sha, $mini_sha, $parents, $msg) =
            ($line =~ /^<(.*?)><(.*?)><(.*?)>(.*)/s);
        my @next_sha = map { ($_) = /^<(.*?)>/ } @next_sha;
        my @parents = split(" ", $parents);

        &vine_branch(\@vine, $sha);
        my $ra = &vine_commit(\@vine, $sha, \@parents);

        if (exists($refs->{$sha})) {
            print &vis_post(&vis_commit($ra),
                  $Color{at}."m".$Color{default});
            &ref_print($refs->{$sha});
        } else {
            print &vis_post(&vis_commit($ra, " "));
        }
        if ($With_sha) {
            print $msg, $Color{at}, encode('UTF-8', "──("), $Color{sha}, $mini_sha,
                  $Color{at}, ")", $Color{default}, "\n";
        } else {
            print $msg, "\n";
        }

        &vine_merge(\@vine, $sha, \@next_sha, \@parents);
    }
    $Repo->command_close_pipe($fh, $fhc);
}

sub get_refs
{
    my($fh, $c) = $Repo->command_output_pipe("show-ref");
    my $ret = {};

    while (defined(my $ln = <$fh>)) {
        chomp $ln;
        if (length($ln) == 0) {
            next;
        }

        my($sha, $name) = ($ln =~ /^(\S+)\s+(.*)/s);
        if (!exists($ret->{$sha})) {
            $ret->{$sha} = [];
        }
        push(@{$ret->{$sha}}, $name);
        if ($name =~ m{^refs/tags/}) {
            my $sub_sha = $Repo->command("log", "-1",
                          "--pretty=format:%H", $name);
            chomp $sub_sha;
            if ($sha ne $sub_sha) {
                push(@{$ret->{$sub_sha}}, $name);
            }
        }
    }

    $Repo->command_close_pipe($fh, $c);

    my $rebase = -e $Repo->repo_path()."/.dotest-merge/git-rebase-todo" &&
                 $Show_rebase;
    if ($rebase) {
        if (open(my $act_fh, $Repo->repo_path().
            "/.dotest-merge/git-rebase-todo")) {
            my($curr) = (<$act_fh> =~ /^\S+\s+(\S+)/);
            $curr = $Repo->command("rev-parse", $curr);
            chomp $curr;
            unshift(@{$ret->{$curr}}, "rebase/cn");
            close $act_fh;
        }

        chomp(my $up   = $Repo->command("rev-parse", ".dotest-merge/upstream"));
        chomp(my $onto = $Repo->command("rev-parse", ".dotest-merge/onto"));
        chomp(my $old  = $Repo->command("rev-parse", ".dotest-merge/head"));
        unshift(@{$ret->{$up}}, "rebase/upstream");
        unshift(@{$ret->{$onto}}, "rebase/onto");
        unshift(@{$ret->{$old}}, "rebase/saved-HEAD");
    }

    my $head = $Repo->command("rev-parse", "HEAD");
    chomp $head;
    if ($rebase) {
        unshift(@{$ret->{$head}}, "rebase/new");
    }
    unshift(@{$ret->{$head}}, "HEAD");

    return $ret;
}

#
# ref_print - print a ref with color
# @s:    ref name
#
sub ref_print
{
    foreach my $symbol (@{shift @_}) {
        print $Color{at}, "[";
        if ($symbol eq "HEAD" || $symbol =~ m{^rebase/}) {
            print $Color{hhead}, $symbol;
        } elsif ($symbol =~ m{^refs/(remotes/[^/]+)/(.*)}s) {
            print $Color{remote}, $1, $Color{head}, "/$2";
        } elsif ($symbol =~ m{^refs/heads/(.*)}s) {
            print $Color{head}, $1;
        } elsif ($symbol =~ m{^refs/tags/(.*)}s) {
            print $Color{tag}, $1;
        } elsif ($symbol =~ m{^refs/(.*)}s) {
            print $Color{ref}, $1;
        }
        print $Color{at}, encode('UTF-8', "]──"), $Color{default};
    }
}

#
# vine_branch -
# @vine:    column array containing the expected parent IDs
# @rev:        commit ID
#
# Draws the branching vine matrix between a commit K and K^ (@rev).
#
sub vine_branch
{
    my($vine, $rev) = @_;
    my $idx;

    my($matched, $master) = (0, 0);
    my $ret;

    # Transform array into string
    for ($idx = 0; $idx <= $#$vine; ++$idx) {
        if (!defined($vine->[$idx])) {
            $ret .= " ";
            next;
        } elsif ($vine->[$idx] ne $rev) {
            $ret .= "I";
            next;
        }
        if (!$master && $idx % 2 == 0) {
            $ret .= "S";
            $master = 1;
        } else {
            $ret .= "s";
            $vine->[$idx] = undef;
        }
        ++$matched;
    }

    if ($matched < 2) {
        return;
    }

    &remove_trailing_blanks($vine);
    print &vis_post(&vis_fan($ret, "branch")), "\n";
}

#
# vine_commit -
# @vine:    column array containing the expected IDs
# @rev:        commit ID
# @parents:    array of parent IDs
#
sub vine_commit
{
    my($vine, $rev, $parents) = @_;
    my $ret;

    for (my $i = 0; $i <= $#$vine; ++$i) {
        if (!defined($vine->[$i])) {
            $ret .= " ";
        } elsif ($vine->[$i] eq $rev) {
            $ret .= "C";
        } else {
            $ret .= "I";
        }
    }

    if ($ret !~ /C/) {
        # Not having produced a C before means this is a tip
        my $i;
        for ($i = &round_down2($#$vine); $i >= 0; $i -= 2) {
            if (substr($ret, $i, 1) eq " ") {
                substr($ret, $i, 1) = "t";
                $vine->[$i] = $rev;
                last;
            }
        }
        if ($i < 0) {
            if (scalar(@$vine) % 2 != 0) {
                push(@$vine, undef);
                $ret .= " ";
            }
            $ret .= "t";
            push(@$vine, $rev);
        }
    }

    &remove_trailing_blanks($vine);

    if (scalar(@$parents) == 0) {
        # tree root
        $ret =~ tr/C/r/;
    }

    return $ret;
}

#
# vine_merge -
# @vine:    column array containing the expected parent IDs
# @rev:        commit ID
# @next_rev:    next commit ID in the revision list
# @parents:    parent IDs of @rev
#
# Draws the merging vine matrix between a commit K (@rev) and K^ (@parents).
#
sub vine_merge
{
    my($vine, $rev, $next_rev, $parents) = @_;
    my $orig_vine = -1;
    my @slot;
    my($ret, $max);

    for (my $i = 0; $i <= $#$vine; ++$i) {
        if ($vine->[$i] eq $rev) {
            $orig_vine = $i;
            last;
        }
    }

    if ($orig_vine == -1) {
        die "vine_commit() did not add this vine.";
    }

    if (scalar(@$parents) <= 1) {
        #
        # A single parent does not need a visual. Update and return.
        #
        $vine->[$orig_vine] = $parents->[0];
        &remove_trailing_blanks($vine);
        return;
    }

    #
    # Put previously seen branches in the vine subcolumns
    # Need to keep at least one parent for the slot algorithm below.
    #
    for (my $j = 0; $j <= $#$parents && $#$parents > 0; ++$j) {
        for (my $idx = 0; $idx <= $#$vine; ++$idx) {
            if ($vine->[$idx] ne $parents->[$j] ||
                !grep { my $z = $vine->[$idx]; /^\Q$z\E$/ }
                @$next_rev) {
                next;
            }
            if ($idx == $orig_vine) {
                die "Should not really happen";
            }
            if ($idx < $orig_vine) {
                my $p = $idx + 1;
                if (defined($vine->[$p])) {
                    $p = $idx - 1;
                }
                if (defined($vine->[$p])) {
                    last;
                }
                $vine->[$p] = $parents->[$j];
                str_expand(\$ret, $p + 1);
                substr($ret, $p, 1) = "s";
            } else {
                my $p = $idx - 1;
                if (defined($vine->[$p]) || $p < 0) {
                    $p = $idx + 1;
                }
                if (defined($vine->[$p])) {
                    last;
                }
                $vine->[$p] = $parents->[$j];
                str_expand(\$ret, $p + 1);
                substr($ret, $p, 1) = "s";
            }
            splice(@$parents, $j, 1);
            --$j; # outer loop
            last; # inner loop
        }
    }

    #
    # Find some good spots to split out into and record columns
    # that will be used soon in the @slot list.
    #
    push(@slot, $orig_vine);
    my $parent = 0;

    for (my $seeker = 2; $parent < $#$parents &&
        $seeker < 2 + $#$vine; ++$seeker)
    {
        my $idx = ($seeker % 2 == 0) ? -1 : 1;
        $idx   *= int($seeker / 2);
        $idx   *= 2;
        $idx   += $orig_vine;

        if ($idx >= 0 && $idx <= $#$vine && !defined($vine->[$idx])) {
            push(@slot, $idx);
            $vine->[$idx] = "0" x 40;
            ++$parent;
        }
    }
    for (my $idx = $orig_vine + 2; $parent < $#$parents; $idx += 2) {
        if (!defined($vine->[$idx])) {
            push(@slot, $idx);
            ++$parent;
        }
    }

    if (scalar(@slot) != scalar(@$parents)) {
        die "Serious internal problem";
    }

    @slot = sort { $a <=> $b } @slot;
    $max  = scalar(@$vine) + 2 * scalar(@slot);

    for (my $i = 0; $i < $max; ++$i) {
        str_expand(\$ret, $i + 1);
        if ($#slot >= 0 && $i == $slot[0]) {
            shift @slot;
            $vine->[$i] = shift @$parents;
            substr($ret, $i, 1) = ($i == $orig_vine) ? "S" : "s";
        } elsif (substr($ret, $i, 1) eq "s") {
            ; # keep existing fanouts
        } elsif (defined($vine->[$i])) {
            substr($ret, $i, 1) = "I";
        } else {
            substr($ret, $i, 1) = " ";
        }
    }

    print &vis_post(&vis_fan($ret, "merge")), "\n";
}

#
# vis_* - transform control string into usable graphic
#
# To cut down on code, the three vine_* functions produce only a dumb,
# but already unambiguous, control string which needs some processing
# before it is ready for public display.
#

sub vis_commit
{
    my $s = shift @_;
    my $f = shift @_;
    $s =~ s/ +$//gs;
    if (defined $f) {
        $s .= $f;
    }
    return $s;
}

sub vis_fan
{
    my $s = shift @_;
    my $b = shift(@_) eq "branch";

    $s =~ s{s.*s}{
        $_ = $&;
        $_ =~ tr/ I/DO/;
        $_;
    }ei;

    # Transform an ODODO.. sequence into a contiguous overpass.
    $s =~ s{O[DO]+O}{"O" x length($&)}eg;

    # Do left/right edge transformation
    $s =~ s{(s.*)S(.*s)}{&vis_fan3($1, $2)}es ||
    $s =~ s{(s.*)S}{&vis_fan2L($1)."B"}es ||
    $s =~ s{S(.*s)}{"A".&vis_fan2R($1)}es ||
    die "Should not come here";

    if ($b) {
        $s =~ tr/efg/xyz/;
    }

    return $s;
}

sub vis_fan2L
{
    my $l = shift @_;
    $l =~ s/^s/e/;
    $l =~ s/s/f/g;
    return $l;
}

sub vis_fan2R
{
    my $r = shift @_;
    $r =~ s/s$/g/;
    $r =~ s/s/f/g;
    return $r;
}

sub vis_fan3
{
    my($l, $r) = @_;
    $l =~ s/^s/e/;
    $l =~ s/s/f/g;
    $r =~ s/s$/g/;
    $r =~ s/s/f/g;
    return "${l}K$r";
}

sub vis_xfrm
{
    # A: branch to right
    # B: branch to right
    # C: commit
    # D:
    # e: merge visual left (╔)
    # f: merge visual center (╦)
    # g: merge visual right (╗)
    # I: straight line (║)
    # K: branch visual split (╬)
    # m: single line (─)
    # O: overpass (≡)
    # r: root (╙)
    # t: tip (╓)
    # x: branch visual left (╚)
    # y: branch visual center (╩)
    # z: branch visual right (╝)
    # *: filler

    my $s = shift @_;
    my $spc = shift @_;
    if ($spc) {
        $s =~ s{[Ctr].*}{
            $_ = $&;
            $_ =~ s{ }{\*}g;
            $_;
        }esg;
    }

    if ($Reverse_order) {
        $s =~ tr/efg.rt.xyz/xyz.tr.efg/;
    }

    if ($Style == 1) {
        $s =~ tr/ABCD.efg.IKO.mrt.xyz/├┤├─.┌┬┐.│┼≡.─└┌.└┴┘/;
    } elsif ($Style == 2) {
        $s =~ tr/ABCD.efg.IKO.mrt.xyz/╠╣╟═.╔╦╗.║╬═.─╙╓.╚╩╝/;
    } elsif ($Style == 10) {
        $s =~ tr/ABCD.efg.IKO.mrt.xyz/├┤├─.╭┬╮.│┼≡.─└┌.╰┴╯/;
    } elsif ($Style == 15) {
        $s =~ tr/ABCD.efg.IKO.mrt.xyz/┣┫┣━.┏┳┓.┃╋☰.━┗┏.┗┻┛/;
    }
    return $s;
}

#
# vis_post - post-process/transform vine graphic
# Apply user-specific style transformation.
#
sub vis_post
{
    my $s = shift @_;
    my $f = shift @_;

    $s = vis_xfrm($s, defined $f);
    $f =~ s/^([^\x1b]+)/vis_xfrm($1)/e;
    $f =~ s/(\x1b.*?m)([^\x1b]+)/$1.vis_xfrm($2)/eg;
    if (defined $f) {
        $s =~ s/\*/$f/g;
        $s =~ s{\Q$Color{default}\E}{$&.$Color{tree}}egs;
        $s .= $f;
    }

    return $Color{tree}, encode('UTF-8', $s), $Color{default};
}

sub remove_trailing_blanks
{
    my $a = shift @_;

    while (scalar(@$a) > 0 && !defined($a->[$#$a])) {
        pop(@$a);
    }
}

sub round_down2
{
    my $i = shift @_;
    if ($i < 0) {
        return $i;
    }
    return $i & ~1;
}

sub str_expand
{
    my $r = shift @_;
    my $l = shift @_;

    if (length($$r) < $l) {
        $$r .= " " x ($l - length($$r));
    }
}

package ReverseOutput;
require Tie::Handle;
@ReverseOutput::ISA = qw(Tie::Handle);

sub TIEHANDLE
{
    my $class = shift @_;
    my $self  = {};

    open($self->{fh}, ">&STDOUT");

    return bless $self, $class;
}

sub PRINT
{
    my $self = shift @_;
    my $fh   = $self->{fh};

    $self->{saved} .= join($\, @_);
}

sub UNTIE
{
    my $self = shift @_;
    my $fh   = $self->{fh};

    print $fh join($/, reverse split(/\n/s, $self->{saved}));
    print $fh "\n";
    undef $self->{saved};
}