Gegenüberstellung [2] (Linux-Magazin, Mai 2002)

Zum letzten Mal vorgestellten Modul SDiff.pm kommt heute das bislang noch fehlende CGI-Skript hd hinzu, das die Unterschiede zwischen Dateiversionen grafisch im Browser darstellt.

Um zwei Dateien im Browser Seite an Seite mittels des heute vorgestellten Skripts zu vergleichen, füllt der Benutzer zunächst nur ein paar Formfelder des CGIs aus, drückt den View-Knopf und schon kalkuliert das Skript im lokalen Webserver die Dateiunterschiede und bringt die farblich unterlegte Darstellung im Browser hoch. hd kennt aber nicht nur normale Dateien, sondern kann auch mit dem Versionskontrollsystem CVS umgehen.

Wer CVS noch nicht kennt, dem sei es auch für kleinere Softwareprojekte wärmstens ans Herz gelegt: Man kann damit ganze Projektbäume verwalten, mit mehreren Leuten daran arbeiten und trotzdem das Projekt konsistent halten. Geht einem Teammitglied eine Datei oder der ganze Baum verloren, kann man beliebige vorher eingecheckte Versionen wieder hervorzaubern, man sagt einfach: ``Wie sah das Projekt letzten Montag um 9:00 aus?'' und schon zaubert CVS den Stand 100%ig wieder her. Editieren zwei Kollegen versehentlich die gleiche Datei, kann cvs den Konflikt meist automatisch (!) wieder bereinigen. Ein Wahnsinnsteil -- und auch noch frei unter [3] verfügbar. Ein Industriestandard. Das Standardwerk [2] zeigt genau, wie's geht.

Der grafische Dateienvergleicher hd (in Erweiterung zu [4]) drei verschiedene Eingabemodi:

Jeder Eingabemodus verfügt der einfachen Bedienbarkeit wegen über eine eigene Eingabeseite, zwischen denen der Benutzer durch Mausklicks auf die Kopfzeilen-Links wechseln kann.

Abbildung 1: Einstellung zum Vergleich zweier Dateien

Abbildung 2: Einstellung zum Vergleich zweier Dateien

Lokaler Vergleich

Abbildung 1 zeigt, wie hd nach der Installation und dem Aufruf über den Browser (z.B. als http://localhost/cgi/hd ) im Modus Compare Two Files steht und zwei Dateinamen samt den zugehörigen Pfaden entgegennimmt. Weiter lässt sich ein Kommentar angeben, den das Skript in der Ausgabe dann als Überschrift ausgibt. Und zwei Optionen als anklickbare Druckknöpfe gibt's noch: stripes, die auch jede zweite unveränderte Zeile zur besseren Sichtbarkeit in einem leichten Grau unterlegt (Abbildung 4) und context, die die beiden Dateien nicht in voller Länge anzeigt, sondern nur großzügige Fenster um die tatsächlich veränderten Zeilen (auch Abbildung 4). Wie Abbildung 2 zeigt, unterlegt das Skript eingefügte oder gelöschte Zeilen hellgrün und Zeilen, die sich änderten, blau.

Abbildung 3: Eine Datei mit der eingecheckten Version vergleichen

Abbildung 4: Kontextanzeige

Kurz vorm Einchecken

Am häufigsten tritt die Situation für ein Code-Review ein, bevor ein Entwickler eine lokal geänderte Datei zurück in den CVS-Projektbaum stellt. Einmal eingecheckt, beeinflussen etwaige Fehler das gesamte Entwicklungsteam und deshalb zahlt es sich oft aus, zu diesem Zeitpunkt ein zweites Augenpaar darauf anzusetzen. Der Entwickler gibt hierzu im Modus Compare before Check-in (Abbildung 3) einfach den Pfad zur lokalen Arbeitskopie ein, wählt über die Auswahlbox die CVS-Wurzel aus, klickt die Anzeigeoptionen nach Wahl und dann den View-Button. hd findet nun selbständig heraus, was die letzte eingecheckte Version der Datei im CVS ist, holt diese hervor, vergleicht sie mit der lokalen Version und zeigt die Veränderungen an.

Zwei CVS-Versionen

Auch zwei eingecheckte Versionen einer Datei kann das Skript vergleichen (Abbildung 5). Im Modus Compare Two CVS Revisions wählt man hierzu zunächst die CVS-Wurzel aus. Ist sie nicht in der Auswahlbox vorhanden, kann man auch einen selbstdefinierten Pfad unter Alternative CVS Root angeben. Im Feld Path to File within CVS gibt man den Pfad zur Datei, ausgehend vom Modulnamen in CVS an und die Felder Rev1 und Rev2 nehmen die beiden zu vergleichenden Versionen (z.B. 1.11 und 1.12) entgegen.

Verschiedene Wurzeln

Die meisten Entwickler arbeiten immer nur mit einem CVS-System (das selbstverständlich beliebig viele Projekte enthalten kann), aber hd erlaubt es, mehrere anzugeben. Dabei muss das CVS-System gar nicht mal auf dem gleichen Server wie hd laufen. Typischerweise erwartet CVS die Wurzel des CVS-Systems in der Environmentvariablen CVSROOT. Diese kann entweder, wie in

    CVSROOT=/path/to/cvs

auf ein CVS auf der lokalen Festplatte verweisen oder über

    CVSROOT=:pserver:mschilli@east.bla.com:/data/cvs

auf einen auf east.bla.com liegenden Server, auf dem der Benutzer mschilli Zugriffsrechte besitzt und auf dem unter /data/cvs eine CVS-Wurzel installiert ist.

Da sich diese Werte nur äußerst selten ändern, erlaubt es hd dem Installierer, den Perl-Code mit den aktuellen CVS-Wurzeln anzupassen, worauf hd sie unter sprechenden Namen in einer Auswahlbox anbietet. Gibt der Benutzer jedoch eine Alternative CVS Root in das Formularfeld darunter ein, überschreibt dies den in der Auswahlbox eingestellten Wert.

CVS-Kommandos

Der Zugriff aufs CVS erfolgt über das Programm cvs, das der Entwickler normalerweise einfach von der Kommandozeile aus startet. Im CGI-Skript verwenden wir Perls open()-Befehl mit der Pipe-Syntax, um Daten für den Dateivergleich mittels cvs aus dem CVS zu extrahieren.

CVS erwartet, dass die Environmentvariable CVSROOT die Wurzel des verwendeten CVS-Systems (lokal oder remote) festlegt. Alternativ kann man CVSROOT dem Programm cvs auch mit dem Parameter -d zustecken:

    $ cvs -d /cvswurzel co \
         -r 1.18 -p modul/pfad/datei

extrahiert (co steht für check out) die Version 1.18 einer Datei datei aus dem Modul (oder Projekt) modul unter dem Unterverzeichnis pfad und gibt deren Inhalt wegen der Option -p auf der Standardausgabe aus. Will der Benutzer die lokale Version einer Datei mit der letzten im aktuellen Projektzweig eingecheckten vergleichen, müssen wir zuerst ermitteln, welche Version denn die zuletzt eingecheckte ist. Das ist oft gar nicht trivial, denn CVS erlaubt es Entwicklern, auf Projektzweigen zu arbeiten, sodass für verschiedene Leute unter Umständen verschiedene Versionen einer Datei aktuell sind. Steht der Benutzer im Arbeitsverzeichnis, kann CVS sie aber ohne Angabe von CVSROOT ermitteln:

    $ cd /arbeits/verzeichnis
    $ cvs status datei.c
    File: datei.c 
    Status: Locally Modified
    Working revision:    1.8     
      Fri Jan 25 07:18:14 2002
    Repository revision: 1.8     
      /home/mschilli/CVSROOT/ix/95/t.pnd,v

Eingecheckt ist also die Version 1.8, die mit dem weiter oben definierten cvs co-Kommando leicht zu extrahieren ist.

Abbildung 5: Einstellung zum Vergleich zweier CVS-Versionen des Moduls C

Implementierung

Listing hd zeigt die Implementierung des CGI-Skripts. Die Zeilen 10-15 ziehen die benötigten Zusatzmodule herein. Aus dem letzten Mal vorgestellten SDiff.pm exportieren wir die sdiff()-Funktion, aus dem für CGI-Skripts unentbehrlichen CGI.pm so ziemlich alles, einschließlich recht exotischer Funktionen wie start_font() und end_pre() und aus CGI::Carp das Tag fatalsToBrowser, das das Skript anweist, bei mit die() ausgelösten Fehlern nicht einfach aufzugeben, sondern noch eine brauchbare Fehlermeldung für den Browser auszugeben, sodass dieser statt "500-Server Error" tatsächlich etwas der Fehlersuche dienliches ausgibt. Den Schönheitspreis kriegt diesen Monat wer anderer, Hauptsache ist, es funktioniert.

File::Basename stellt Funktionen wie basename() und dirname() zur praktischen Pfadmanipulation zur Verfügung, Set::IntSpan verwaltet, wie letztes Mal schon besprochen, elegant Integer-Bereiche (z.B. ``1-4,9-12,17-20'') und Text::Wrap schließlich bricht Zeilen schön an Wortgrenzen um. Wie immer ist das CPAN die halbe Miete, man muss es nur zu nutzen wissen.

Zeile 17 setzt $| auf einen wahren Wert und entpuffert damit die Standardausgabe -- zum Debuggen eines CGI-Skripts immer eine gute Idee. Zeile 19 gibt den Pfad zum cvs-Programm an, das den meisten Unix-Installationen von Haus aus beiliegt. Wer's nicht hat und es nicht nutzen will, braucht sich nicht zu sorgen, kann es so stehen lassen und nur lokale Dateien vergleichen.

In Zeile 20 definiert der Array @CVSROOT die Anzeige der Auswahlbox für die CVS-Wurzelverzeichnisse. Mehr dazu im Abschnitt Installation.

Die Zeilen 30 bis 37 definieren Farben, in denen der Webbrowser verschiedene Zustände anzeigt, entweder als String (z.B. ``lightgreen'' oder als Hex-RGB-Zahl (z.B. #c0c0c0). Dort wird ersichtlich, dass Einfügungen und Löschungen mit der gleichen Farbe ("lightgreen"), dargestellt werden -- wer das anders will, kann es gerne ändern.

@MODES in Zeile 38 ist ein Array mit zweielementigen Unterarrays, die den drei Modi-Kürzeln file, cvs und chi die entsprechenden Beschreibungen zuordnen.

$CONTEXT in Zeile 42 gibt mit dem Wert 5 an, dass wir im context-Modus später 5 Zeilen vor und hinter jedem Fenster mit Codeveränderungen sehen wollen.

$MAX_LINE_LEN in Zeile 43 definiert die angestrebte Spaltenbreite, nach der ein Zeilenumbruch erfolgen soll.

Zeile 45 gibt den Header für das CGI-Skript aus -- lebensnotwendig, sonst leitet der Server einen 500er-Fehler an den Browser weiter. Anschließend wird in Zeile 47 geprüft, ob wir entweder den Query-Parameter cfg erhielten oder ob der Parameter mode nicht gesetzt ist, der (wie oben besprochen) einen von drei verschiedenen Modi selektiert, in denen hd operiert. In beiden Fällen stellt hd kein Ergebnis dar, sondern fordert den Benutzer dazu auf, Dateien/Versionen zu spezifizieren, die er später vergleichen will. Die ab Zeile 277 definierte Funktion config_page() zeigt entsprechend dem einstellten Modus entsprechenden Formularfelder an und verlangt Eingaben.

Das CGI-Modul extrahiert vom Benutzer eingegebene/selektierte und vom Browser übermittelte Parameter einfach praktisch mittels param(name).

Zeile 56 legt die CVS-Wurzel entweder als in der Auswahlbox spezifizierten Eintrag oder als im Feld ``Alternative CVS Root'' festgelegten Wert in der Variablen $cvsroot ab. Eine alternativ festgelegte Wurzel überstimmt die Auswahlbox.

Das If-Else-Konstrukt zwischen den Zeilen 59 und 96 ermittelt für die drei Fälle Dateivergleich (file), Eincheckkontrolle (chi) und CVS-Vergleich cvs die folgenden Werte:

In den drei verschiedenen Modi kommen außerdem die folgenden Parameter vom Benutzer über den Browser herein:

Die weiter unten ab Zeile 217 definierte Funktion readfile ist ein Tausendsassa, der Dateien/Revisionen einliest und eine Liste mit zwei Werten zurückgibt: Eine Referenz auf einen Array mit den Zeilen der Datei und einen Skalar mit der gefundenen Revisionsnummer. Als Eingabeparameter fungieren flexible Parameterlisten im Format name => wert. So liest

    readfile(file => "datei");

eine normale Datei ein, während

    readfile(cvsroot => "...",
             lpath   => "pfad");

die letzte eingecheckte Version einer Datei holt und

    readfile(cvsroot => "...",
      rpath => "proj/dir/datei");
      rev   => "1.18");

schließlich schnappt sich genau die angegebene Version des Projekts proj der Datei dir/datei aus dem mit cvsroot angegebenen CVS. Den Zeilen des zurückkommenden Arrays hängen noch Zeilenumbrüche an, mit den machen die chomp-Befehle in den Zeilen 98 und 99 kurzen Prozess.

Wie letztes Mal besprochen, braucht die Funktion sdiff(), die die Dateiunterschiede ermittelt, ein Set::IntSpan-Objekt, um die interessanten Bereiche zu kennzeichnen. Zeile 101 erzeugt dieses und 103 lässt das SDiff-Modul den Unterschied zwischen den Zeilen in $left und $right ermitteln und in $sdiff ablegen. $CONTEXT gibt die Breite der Codefenster für die Anzeige der Veränderungen an.

Das if-Konstrukt ab Zeile 107 korrigiert die Bereichsangaben mit den Zeilen, die tatsächlich angezeigt werden sollen. Hat die Datei 100 Zeilen und in @run_list steht

    @run_list = ("20-30", "50-60");

soll die Ausgabe so aussehen:

    Skipping lines 1...19
    20 ...
    ...
    30 ...
    Skipping lines 31...49
    50 ...
    ...
    60 ...
    Skipping lines 61...100

falls der Benutzer die Option context gewählt hat. Falls nicht, kommen einfach alle Zeilen untereinander daher. Der Array @run_list enthält als Elemente einfach alle Zeilenbereiche im Format anfang-ende, die die Methode run_list() eines Set::IntSpan-Objects als einzelnen String und komma-separiert liefert. Soll statt der Kontext-Anzeige die ganze Datei ungekürzt erscheinen, erzeugt Zeile 112 einen Zeilenbereich, der die ganze Datei umfasst.

Ab Zeile 115 startet die HTML-Ausgabe, angefangen mit der Definition der zweispaltigen Ausgabetabelle und gefolgt vom angegebenen Kommentartext und den Spaltenüberschriften, die Hinweise auf die dort dargestellten Dateien geben und vorher in $h1 und $h2 festgelegt wurden.

Die while-Schleife ab Zeile 139 gibt die Dateidifferenzen farblich unterlegt aus. Die Show läuft, solange entweder noch Zeilen im SDiff-Array sind oder Anweisungen in der Run-List. Steht in letzterer nichts mehr, sind wir an den im SDiff-Array verbleibenden Zeilen nicht mehr interessiert. In Zeile 150 nimmt die ab Zeile 198 definierte Funktion skip() eine Referenz auf den Array, den aktuellen Index (Zeilennummer minus 1) und den Index der letzten zu überspringenden Zeile entgegen und liefert den neuen Index zurück, den wir wieder in $cur_idx ablegen. Im Falle einer leeren Run-List bricht last in Zeile 152 jedoch den Ausgabereigen ab. Zeile 155 behandelt den Fall, dass der gerade zu behandelnde Bereich aus genau einer Zeile besteht (sollte selten vorkommen). Falls der aktuelle Index kleiner ist als der von der neuen Run-List bestimmte Startwert, müssen noch einige Zeile aus $diff verschwinden und die entsprechende Skipping...-Meldung erfolgen, was skip() in 159 wie oben gerne übernimmt.

Ab Zeile 163 kommt dann tatsächlich die farblich unterlegte Diff-Ausgabe dran. Wie letztes Mal besprochen, liefert SDiff ja pro Zeile dreielementige Arrays zurück, die die Zeileninhalte und einen Modifizierungsmerker (c: Change, i: Insert, d: Delete, u: Unmodified) enthalten. Das Konstrukt in Zeile 168 weisst den entsprechenden Merkern die richtigen Farben zu und macht sich dabei zunutze, dass man Perls ternären ?:-Operator beliebig strecken kann, um if-elsif-ähnliche Funktionalität zu erhalten.

Zeile 175 formatiert die aktuelle Zeilennummer zweispaltig und Zeile 177 unterlegt jede zweite unmodifizierte Zeile gräulich, falls der Benutzer den striped-Modus wählte, der Listings zur besseren Augenführung gestreift darstellt (Abbildung 2 vs. Abbildung 4).

Dann wird die Tabelle abgeschlossen und ein Link auf die Konfigurationsseite ausgegeben, den die Funktion self_url() automatisch erzeugt, nachdem vorher cfg mit param() auf 1 gesetzt wurde.

readfile() ab Zeile 217 bastelt in $cmd zunächst das Kommando für die open()-Funktion zusammen. Für den Fall einer simplen Datei ist dies einfach "<datei". Für CVS-Zugriffe hingegen steht dort das CVS-Kommando, gefolgt von einem Pipe (|)-Zeichen, damit die später in Zeile 237 aufgerufene open()-Funktion es als Shell-Kommando ausführt und im entsprechend zugeordneten Filehandle die Ausgabezeilen liefert. Hat der Benutzer nicht die gewünschte CVS-Revisionsnummer in $opts{rev} angegeben, muss die Funktion cvs_fill_in() diese schlau ermitteln und in $opts{rev} ``einfüllen''. Außerdem wird der relative Pfad vom CVS-Repository zur Datei gebraucht.

Da der Benutzer mit dem CGI-Parameter path (lpath für readfile()) nur den absoluten Pfad zur lokalen Arbeitskopie angibt, muss cvs_fill_in() zu einem Trick greifen: CVS legt in jedem Arbeitsverzeichnis ein Verzeichnis namens CVS an und legt dort administrative Dateien ab. Darunter ist auch Repository, die als einzige Information genau den gesuchten relativen Pfad enthält. Zeile 255 hängt noch schnell einen Schrägstrich und den Namen der zu vergleichenden Datei dahinter und legt das Ergebnis in $opts-<{rpath} -- fertig. Das ab Zeile 264 zusammengebastelte cvs status-Kommando wird die aktuell eingecheckte Version ermitteln und in $opts{rev} ablegen -- und damit haben wir alles zusammen, um das in readfile() ab Zeile 232 zusammengebaute cvs co-Kommando auszuführen und die Datei in der geforderten Version aus dem CVS zu locken. -Q verdonnert cvs übrigens dazu, kein unnützes Geschwätz abzusondern.

config_page() ab Zeile 277 gibt die verschiedenen Browser-Formulare aus und nutzt dazu die praktischen HTML-Ausgabebefehle aus Lincoln Steins CGI-Modul. Der CGI-Parameter mode bestimmt, welcher der drei Eingabemodi erscheint. Um Codezeilen formatiert auszugeben, ersetzt die Funktion type() ab Zeile 355 alle für HTML gefährlichen Zeichen wie &, < und >, nutzt das Modul Text::Wrap, um zu lange Zeilen an Wortgrenzen umzubrechen (andernfalls werden die Tabellenspalten bei langen Codezeilen zu breit), stellt noch einen speziellen Font mit konstanter Zeichenbreite ein und gibt alle ihm übergebenen Textstücke HTML-formatiert zurück. Die Funktion display_cvs_form() gibt Formularfelder für zwei verschiedene CVS-Modi aus und wurde deshalb nach Zeile 374 ausgelagert. Sie greift sich den Array @CVSROOT und gibt dem Benutzer ein schönes Drop-Down-Menü für die Auswahl der CVS-Wurzel und ein Eingabefeld für etwaige andere CVS-Wurzeln.

Listing 1: hd

    001 #!/usr/bin/perl
    002 ###########################################
    003 # hd - Diff files side by side in HTML
    004 # Derived from 'hdiff' (Bonsai team)
    005 # Mike Schilli, 2002 (mschilli1@aol.com)
    006 ###########################################
    007 use warnings;
    008 use strict;
    009 
    010 use SDiff qw(sdiff);
    011 use CGI qw(:all *table *font *pre);
    012 use CGI::Carp qw(fatalsToBrowser);
    013 use File::Basename;
    014 use Set::IntSpan;
    015 use Text::Wrap;
    016 
    017 $| = 1;
    018 
    019 my $CVS_COMMAND = "/usr/bin/cvs";
    020 my @CVSROOTS = (
    021   ["/home/mschilli/CVSROOT", 
    022    "My Local CVS"],
    023   [":pserver:anonymous\@anoncvs.gimp.org:" .
    024    "/cvs/gnome", 
    025    "Gimp"],
    026   [":pserver:mschilli\@east." .
    027    "bla.com:/data/cvs/host", 
    028    "Server"]);
    029 
    030 my $STABLE_BG_COLOR   = "White";
    031 my $ALT_BG_COLOR      = "#f0f0f0";
    032 my $SKIPPING_BG_COLOR = "#c0c0c0";
    033 my $HEADER_BG_COLOR   = "Orange";
    034 my $CHANGE_BG_COLOR   = "LightBlue";
    035 my $ADDITION_BG_COLOR = "LightGreen";
    036 my $DELETION_BG_COLOR = "LightGreen";
    037 my $DIFF_BG_COLOR     = "White";
    038 my @MODES = (
    039     [file => "Compare Two Files"],
    040     [cvs  => "Compare Two CVS revisions"], 
    041     [chi  => "Compare before Check-In"]);
    042 my $CONTEXT      = 5;
    043 my $MAX_LINE_LEN = 80;
    044 
    045 print header();
    046 
    047 if(param("cfg") or !param("mode")) {
    048     # Display config page
    049     config_page();
    050     exit 0;
    051 }
    052 
    053 my($h1, $h2, $left, $right, $n,
    054    @run_list, $nof_lines, $ver);
    055 
    056 my $cvsroot = 
    057      (param("aroot") || param("cvsroot"));
    058 
    059 if(param("mode") eq "file") {
    060         # Compare two files
    061     $h1 = basename(param("f1"));
    062     $h2 = basename(param("f2"));
    063 
    064     ($left)  = readfile(file => 
    065                        param("f1"));
    066     ($right) = readfile(file => 
    067                        param("f2"));
    068 
    069 } elsif(param("mode") eq "chi") {
    070         # Compare a local version with CVS
    071     ($left, $ver) = readfile(
    072         lpath   => param('path'),
    073         cvsroot => $cvsroot);
    074 
    075     my $filename = basename(param("path"));
    076     $h1 = "$filename (CVS $ver)";
    077     $h2 = "$filename (local copy)";
    078 
    079     ($right) = readfile(
    080         file   => param('path'));
    081 
    082 } elsif(param("mode") eq "cvs") {
    083         # Compare two CVS versions
    084     my $f = basename(param("path"));
    085     $h1 = "$f (CVS " . param("r1") . ")";
    086     $h2 = "$f (CVS " . param("r2") . ")";
    087 
    088     ($left)  = readfile(
    089        cvsroot => $cvsroot,
    090        rpath   => param("path"),
    091        rev     => param("r1"));
    092     ($right) = readfile(
    093        cvsroot => $cvsroot,
    094        rpath   => param("path"),
    095        rev     => param("r2"));
    096 }
    097 
    098 chomp @$left;
    099 chomp @$right;
    100 
    101 my $set = Set::IntSpan->new();
    102 
    103 my $diffs = sdiff($left, $right, \$set, 
    104                   $CONTEXT);
    105 $nof_lines = @$diffs;
    106 
    107 if(param("context")) {
    108     @run_list = split /,/, 
    109                       $set->run_list();
    110     @run_list = () if $set->empty();
    111 } else {
    112     @run_list = ("0-$#$diffs");
    113 }
    114 
    115 print start_html( {BGCOLOR => 
    116                    $STABLE_BG_COLOR,
    117                    -title => 
    118                    param("comment")});
    119 
    120 param("cfg" => 1);
    121 print a({href => self_url}, "Configure");
    122 
    123 print start_table( {BGCOLOR     => 
    124                           $STABLE_BG_COLOR,
    125                     RULES       => "all",
    126                     CELLPADDING => 0,
    127                     CELLSPACING => 0,
    128                     COLS        => 2}
    129                  );
    130 
    131 print TR( {BGCOLOR => $DIFF_BG_COLOR}, 
    132           th( {colspan => 2}, 
    133               param("comment")) );
    134 print TR( {BGCOLOR => $HEADER_BG_COLOR}, 
    135           th($h1), th($h2) );
    136 
    137 my $cur_idx = 0;
    138 
    139 while(@$diffs or @run_list) {
    140 
    141     my($from, $to);
    142 
    143     if(@run_list) {
    144         ($from, $to) = split /-/, 
    145                            shift @run_list;
    146     }
    147 
    148     if(!defined $from) {
    149         # Skip until the end
    150         $cur_idx = skip($diffs, $cur_idx, 
    151                             $nof_lines-1);
    152         last;
    153     }
    154         # Just one line? 
    155     $to = $from unless defined $to;
    156 
    157     if($cur_idx < $from) {
    158         # There are lines to skip
    159         $cur_idx = skip($diffs, $cur_idx, 
    160                         $from-1);
    161     }
    162 
    163     for($cur_idx..$to) {
    164         my $e = shift @$diffs;
    165         my($left, $right, $mod) = @$e;
    166         $cur_idx++;
    167 
    168         my $color =
    169          $mod eq "c" ? $CHANGE_BG_COLOR : 
    170          $mod eq "i" ? $ADDITION_BG_COLOR :
    171          $mod eq "d" ? $DELETION_BG_COLOR :
    172          $mod eq "u" ? $STABLE_BG_COLOR : 
    173                  "unknown";
    174 
    175         $n = sprintf "%2d", $cur_idx;
    176 
    177         $color = $ALT_BG_COLOR if 
    178                $mod eq "u" 
    179                and param("striped") 
    180                and $n % 2;
    181 
    182         print TR({BGCOLOR => $color}, 
    183                  td({align => "left"}, 
    184                     type("$n $left")),
    185                  td({align => "left"}, 
    186                     type("$n $right")));
    187     
    188         print "\n";
    189     }
    190 }
    191 
    192 print end_table();
    193 param("cfg", "1");
    194 print a({href => self_url}, "Configure");
    195 print end_html();
    196 
    197 ###########################################
    198 sub skip {
    199 ###########################################
    200     my($diffs, $cur_idx, $to) = @_;
    201 
    202     if($to-$cur_idx > 2*$CONTEXT) {
    203         print TR( {BGCOLOR => 
    204                    $SKIPPING_BG_COLOR}, 
    205                   td( { COLSPAN => 2 }, 
    206                       b("Skipping lines ", 
    207                         $cur_idx + 1,
    208                         "...", $to + 1)));
    209         splice @$diffs, 0, $to-$cur_idx+1;
    210         $cur_idx = $to+1;
    211     }
    212 
    213     return $cur_idx;
    214 }
    215 
    216 ###########################################
    217 sub readfile {
    218 ###########################################
    219     my (%opts) = @_;
    220 
    221     my $cmd;
    222 
    223         # Local file
    224     if(exists $opts{file}) {
    225         $cmd = "<$opts{file}";
    226     } else {
    227             # Get latest CVS version
    228         if(!exists $opts{rev}) {
    229             cvs_fill_in(\%opts);
    230         }
    231             # Get file
    232         $cmd = "$CVS_COMMAND -Q -d " . 
    233          "$opts{cvsroot} co -r " .
    234          "$opts{rev} -p $opts{rpath} |";
    235     }
    236 
    237     open F, "$cmd" or 
    238                   die "Cannot open '$cmd'";
    239     my @data = <F>;
    240     close F or die "$cmd failed";
    241     return (\@data, $opts{rev});
    242 }
    243 
    244 ###########################################
    245 sub cvs_fill_in {
    246 ###########################################
    247     my ($opts) = @_;
    248 
    249         # Get path within CVS/working path
    250     my $rep = dirname($opts->{lpath}) .
    251               "/CVS/Repository";
    252     open FILE, "<$rep" or 
    253           die "Cannot open $rep";
    254     chomp($opts->{rpath} = <FILE>);
    255     $opts->{rpath} .= "/" . 
    256                  basename($opts->{lpath});
    257     close FILE;
    258 
    259         # Get cvs version
    260     my $wdir = dirname($opts->{lpath});
    261     chdir $wdir or 
    262               die "Cannot chdir to $wdir";
    263 
    264     my $cmd = "$CVS_COMMAND -Q -d " .
    265           "$opts->{cvsroot} status " .
    266           basename($opts->{lpath}) .
    267           " 2>/dev/null";
    268     open PIPE, "$cmd |" or 
    269          die "Cannot open pipe";
    270     my $data = join '', <PIPE>;
    271     close PIPE or die "$cmd failed";
    272     ($opts->{rev}) = $data =~ 
    273     /Repository revision:\s*([\d\.]+)/;
    274 }
    275 
    276 ###########################################
    277 sub config_page {
    278 ###########################################
    279     print h1("Configuration");
    280 
    281     my $checked = "";
    282     my $current_mode = (param("mode") || 
    283                        "file");
    284     for (@MODES) {
    285         my ($mode, $text) = @$_;
    286         if($mode eq $current_mode) {
    287             print b($text);
    288         } else {
    289             param("mode", $mode);
    290             param("cfg", 1);
    291             print a({href => self_url}, 
    292                     $text);
    293         }
    294         print "&nbsp;\n";
    295     }
    296 
    297         # Set it back to current mode
    298     param("mode", $current_mode);
    299     param("cfg", undef);
    300 
    301     print start_html( {BGCOLOR => 
    302                        $STABLE_BG_COLOR} );
    303     print start_form(-method => "GET");
    304     print start_table({BGCOLOR => 
    305                        $ALT_BG_COLOR});
    306 
    307     if($current_mode eq "file") {
    308         print TR(td("File 1"), 
    309                td(textfield(-name => "f1", 
    310                             -size => 50)));
    311         print TR(td("File 2"), 
    312                td(textfield(-name => "f2", 
    313                             -size => 50)));
    314 
    315     } elsif($current_mode eq "chi") {
    316         display_cvs_form();
    317 
    318         print TR(td("Local Path and File"), 
    319              td(textfield(-name => "path", 
    320                           -size => 50)));
    321 
    322     } elsif($current_mode eq "cvs") {
    323 
    324         display_cvs_form();
    325 
    326         print TR(
    327            td("Path to file within CVS"), 
    328            td(textfield(-name => "path", 
    329                         -size => 50)));
    330 
    331         print TR(td("Rev 1"), 
    332            td(textfield(-name => "r1", 
    333                         -size => 10)));
    334         print TR(td("Rev 2"), 
    335                td(textfield(-name => "r2", 
    336                             -size => 10)));
    337     }
    338 
    339     print TR(td("Comment"), 
    340            td(textfield(-name => "comment", 
    341                         -size => 50)));
    342     print end_table();
    343     print hidden(-name => "mode");
    344     print checkbox(-name => "striped", 
    345                    -value => "on");
    346     print checkbox(-name => "context", 
    347                    -value => "on");
    348     print br();
    349     print submit(value => "View Diff");
    350 
    351     print end_form();
    352 }
    353 
    354 ###########################################
    355 sub type { 
    356 ###########################################
    357 
    358     @_ = map { s/&/&amp;/g;
    359                s/</&lt;/g; s/>/&gt;/g;
    360                $_ } @_;
    361 
    362     $Text::Wrap::columns = $MAX_LINE_LEN;
    363     $_[0] = join "\n", wrap("", "", $_[0]);
    364 
    365     return start_pre() .
    366            start_font(
    367              { FACE => "Lucida Console",
    368                SIZE => 1 }) . 
    369            join('', @_) .
    370            end_font();
    371 }
    372 
    373 ###########################################
    374 sub display_cvs_form { 
    375 ###########################################
    376     my %CVSROOTS = map { @$_ } @CVSROOTS;
    377 
    378     print TR(td("CVS Root"), 
    379              td(popup_menu(
    380      -name   => 'cvsroot',
    381      -values => [map {$_->[0]} @CVSROOTS],
    382      -labels => \%CVSROOTS)));
    383 
    384     print TR(td("Alternative CVS Root"), 
    385              td(textfield(-name => "aroot", 
    386                           -size => 50)));
    387 }

CVS Falle

Da das Skript hd im Webserver unter dessen Benutzerkennung läuft erfolgen auch die Zugriffe aufs CVS unter dieser Kennung, die folgerichtig einen Zugang zum CVS benötigt. Falls dieses den Zugriff beschränkt (was bei Heiminstallationen meist nicht der Fall ist), muss man unter Umständen den Webserverbenutzer von nobody auf eine Kennung mit CVS-Zugang ändern und vor Benutzung des Skripts dem CVS-Server Nutzername und Passwort mittels cvs login mitteilen.

hd ist allerdings so schlau, dass es nur eine Kennung zum CVS braucht, während auch andere Benutzer mit ihrem Browser am Webserver andocken können, um ihre lokalen Dateien gegen die CVS-Versionen zu vergleichen -- solange letztere vom Webserver-Benutzer zumindest lesbar sind.

Sicherheit

Da das Skript den Inhalt von Dateien auf dem entsprechenden Rechner anzeigt, versteht es sich von selbst, dass es aus Sicherheitsgründen niemals auf einem öffentlich zugänglichen Rechner installiert wird, sondern nur innerhalb der Firewall.

Installation

Der Array @CVSROOT in hd muss noch an die lokalen Gegebenheiten angepasst werden -- hier sollten die lokalen CVS-Verzeichnisse bzw. Server sprechenden Namen zugeordnet werden. Ein Wertepaar wie

    ["/u/mschilli/CVSROOT", 
     "Mei Gruschtlkistn"],

im Array @CVSROOT beispielsweise lässt den Browser in der Auswahlbox Mei Gruschtlkistn anzeigen, während der zugehörige CVSROOT-Wert -- falls diese Option gewählt wurde -- auf /u/mschilli/CVSROOT steht.

Die Variable $CVS_COMMAND legt den Pfad zum cvs-Kommando fest, wer will, kann auch noch immer benutzte Optionen wie z.B. -z3 für Komprimierung bei der Datenübertragung dahinterhängen.

Vernünftigerweise sollte das Skript unter einer einigermaßen neuen perl-Installation mit den nachgerüsteten Modulen Set::IntSpan, Text::Wrap und Algorithm::Diff laufen. Letztere kommen über die praktische CPAN-Shell einfach mit

    perl -MCPAN -eshell
    cpan> install Set::IntSpan
    cpan> install Text::Wrap
    cpan> install Algorithm::Diff

vom CPAN auf den heimischen Rechner.

Das Skript hd muss ausführbar ins cgi-bin-Verzeichnis des Webservers und das letztes Mal vorgestellte SDiff.pm irgendwohin, wo hd es findet, also unter Umständen ebenfalls nach cgi-bin. Richtet man den Webbrowser anschließend auf http://rechner/cgi-bin/hd, kommt das Konfigurationsfenster nach Abbildung 1 hoch. Nach Auswahl des richtigen Modus (Datei-, lokal/CVS- oder CVS-Vergleich) und der Eingabe der Pfade/Versionen stellt der Browser die Unterschiede dar und für das Code-Review schickt man den URL per Email oder URL zu den Kollegen. Die können sogar noch die Anzeigeparameter verstellen, in dem sie dem ``Configure''-Link folgen. Und zum Codereview noch einen allgemeinen Tipp, den Damian Conway einmal auf der Perl-Konferenz gab: Nichts ist so schön, wie einen Experten zu Fall zu bringen!

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2002/05/Perl

[2]
Moshe Bar, Karl Franz Fogel, ``Open Source Development with CVS'', 2nd Edition, Coriolis, ISBN 158880173

[3]
Die Homepage des Versionskontrollsystems CVS: http://www.cvshome.org

[4]
Eine Python-Lösung des hdiff-Problems: http://viewcvs.sourceforge.net

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.