hxtools/sdevel/git-forest

667 lines
14 KiB
Perl
Executable File

#!/usr/bin/perl
# SPDX-License-Identifier: GPL-2.0-or-later
# SPDX-FileCopyrightText: 2008 Jan Engelhardt
#
# git-森林
# text-based tree visualisation
#
# For disambiguation, "vine" refers to a branch in the graphic sense.
#
use Getopt::Long;
use Git;
use strict;
use utf8;
use open qw(:std :encoding(UTF-8));
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", # ]
);
my @hist;
&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;
}
}
#
# Cache the output from command_output_pipe() so that we have an idea of what
# commits are to come.
#
# [--date-order example from git-forest.1 manpage]:
# get_line_block() -> (M0, G0, G1)
# get_line_block() -> (G0, G1, G2/M1)
# get_line_block() -> (G1, G2/M1, N0)
# ...
#
sub get_line_block
{
my($fh, $max) = @_;
while (scalar(@hist) < $max) {
my $x;
$x = <$fh>;
if (!defined($x)) {
last;
}
push(@hist, $x);
}
my @ret = (shift @hist);
foreach (2..$max) {
push(@ret, (split(',', $hist[$_-2], 2))[0]);
}
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) = split(',', $line, 4);
my @parents = split(" ", $parents);
&vine_branch(\@vine, $sha);
my $ra = &vine_commit(\@vine, $sha, \@parents);
if (exists($refs->{$sha})) {
$ra =~ s{([Ct].*)}{
my $x = $1;
$x =~ tr{ }{h};
$x;
}eg;
}
print &vis_xfrm($ra);
if (exists($refs->{$sha})) {
&ref_print($refs->{$sha});
}
print " ", $Color{default};
if ($With_sha) {
print $msg, $Color{at}, "──(", $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_next_pick
{
my $fh = shift @_;
while (defined(my $line = <$fh>)) {
if ($line =~ /^\s*#/) {
next;
}
if ($line =~ /^\S+\s+(\S+)/) {
return $2;
}
}
return undef;
}
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()."/rebase-merge/git-rebase-todo" &&
$Show_rebase;
if ($rebase) {
if (open(my $act_fh, $Repo->repo_path().
"/rebase-merge/git-rebase-todo")) {
my($curr) = (<$act_fh> =~ /^\S+\s+(\S+)/);
$curr = &get_next_pick($act_fh);
if (defined($curr)) {
$curr = $Repo->command("rev-parse", $curr);
chomp $curr;
unshift(@{$ret->{$curr}}, "rebase/next");
}
close $act_fh;
}
chomp(my $onto = $Repo->command("rev-parse", "rebase-merge/onto"));
unshift(@{$ret->{$onto}}, "rebase/onto");
}
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) {
if ($Color{remote} eq "") {
print "remotes/$1/$2";
} else {
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}, "]";
}
}
#
# vine_branch -
# @vine: column array containing the expected parent IDs
# @rev: commit ID
#
#
# Draws a transition line (with what would be "diagonals" in classic git-log).
# The transition occurs between commit K and K^ (@rev)
# Such a line will have no commit or commit text.
#
# If the commit is a merge commit, this function will assign columns to parents
# 2..max (HEAD^1 obviously has an assignment already from vine_commit()), then
# draw the transition line.
#
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_xfrm(&vis_fan($ret, "branch")), $Color{default}, "\n";
}
#
# vine_commit -
# @vine: column array containing the expected IDs
# @rev: commit ID
# @parents: array of parent IDs
#
# Draw the vines for a regular commit line.
#
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
#
# If the commit is a merge commit, this function will assign columns to parents
# 2..max (HEAD^1 obviously has an assignment already from vine_commit()), then
# draw a line with transition graphics. (What would be "diagonals" in classic
# git-log).
#
sub vine_merge
{
my($vine, $rev, $next_rev, $parents) = @_;
my $orig_vine = -1; # "current column"
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) {
#
# No need for transition graphics. And update the reservation
# on this column for the (only) parent commit.
#
$vine->[$orig_vine] = $parents->[0];
return;
}
#
# This is where subvines are assigned.
#
# 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
}
}
#
# Alternatingly look left and right of the current column ($orig_vine)
# for free candidate columns (at worst, we will always find room to the
# far right end). orig_vine is already one usable column. Picture this:
#
# ...
# │ ├ │ something completely different
# ├-┘ │
# │ ├ develop: merge feature [current commit]
# │ ┌-┤ [the line we are about to draw]
# │ │ ├ feature 2
# │ ├ │ develop: merge something else
# ...
#
my @slot = ($orig_vine);
for (my $seeker = 2; $#slot < $#$parents; ++$seeker) {
my $idx = $orig_vine + (($seeker % 2 == 0) ? -1 : 1) * ($seeker & ~1);
# seek to the right only
#my $idx = $orig_vine + ($seeker - 1) * 2;
if ($idx >= 0 && !defined($vine->[$idx])) {
push(@slot, $idx);
# mark as reserved so it is not found again for the
# next $seeker iteration
$vine->[$idx] = "0" x 40;
}
}
@slot = sort { $a <=> $b } @slot;
$max = scalar(@$vine) + 2 * scalar(@slot);
#
# Use the so-found columns and assign them to parents. As it is
# customary to visually keep the vine for HEAD^x always left of
# HEAD^(x+1), HEAD^1 may now be shifted to the left, as in the picture
# above. (The alternative would be to forbid left-looking in the seeker
# loop. But that could leave a lot of blank columns in big histories.)
#
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_xfrm(&vis_fan($ret, "merge")), $Color{default}, "\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 (╗)
# h: dark dash for ref_print (─)
# 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}{....};
}
$s =~ s{h}{$Color{at}$Color{tree}}g;
return $Color{tree}.$s;
}
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");
binmode $self->{fh}, ":utf8";
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})), "\n";
undef $self->{saved};
}