Um die Vorgänge in einem Git-Repo in Echtzeit zu sehen, bringt Mike Schilli geänderte Dateien in einem Terminalmonitor in Go farblich zum Vorschein.
Als Dinosaurier aus der Steinzeit der Datenverarbeitung bevorzuge ich es nach wie vor, Code und Text mittels Kommandozeilen-Tools zu schreiben. Beim schnellen Ändern, Kompilieren und Ausprobieren einer Go-Datei mit Vim stünde eine IDE nur im Weg. Allerdings tippe ich zugegebenermaßen oft »git status«, um festzustellen, ob alle Änderungen eingecheckt sind. Es ist schon vorgekommen, dass ich vergessen habe, eine Datei in den Git-Baum einzubinden, und mir dann auf Reisen mit dem Laptop die Haare gerauft habe, weil eine Änderung nur auf dem Rechner daheim lag, aber nicht im Git-Repo.
Deshalb wäre es schön, den Zustand eines Git-Verzeichnisses schon während der Arbeit in Echtzeit zu sehen: Welche Dateien habe ich modifiziert, welche neu erzeugt, und welche fehlen noch im Baum? Einige handgestrickte Shell-Prompts könnten hier nachhelfen. Schicker wäre freilich ein neues Tool namens Gitwatch, das in einem separaten Terminal läuft, den lokalen Baum anzeigt, dabei ratzfatz auf Änderungen reagiert und diese auffällig einfärbt (Abbildung 1).
Nachfragen oder Meldung machen?
Wie bekommt das Werkzeug Änderungen in den angezeigten Dateien mit, sodass es flugs seine Anzeige auffrischen kann? Im einfachsten Fall fragt es in regelmäßigen Abständen nach und erkundigt sich mittels »git status« nach dem Stand der Dinge. Der anglophile Fachmann nennt das Polling.
Doch der eingestellte zeitliche Abstand dieser Nachfragen hat tiefgreifende Konsequenzen. Holt das Tool zum Beispiel nur alle 60 Sekunden die neuesten Daten, dann tippt der User in Dateien herum und wundert sich, dass das Tool keine Reaktion zeigt. Quengelt das Tool andererseits im Sekundentakt, bekommt es zwar Änderungen praktisch verzögerungsfrei mit, verschwendet aber Systemressourcen, da sich meist überhaupt nichts geändert hat.
Schlauer ist da ein Mechanismus, der umgekehrt das Tool benachrichtigt, falls sich etwas im Dateisystem ändert. Dazu bietet Linux standardmäßig unter POSIX den Inotify-Mechanismus [1], der den Kernel instruiert, Callback-Funktionen anzusteuern, sobald Änderungen in einem überwachten Verzeichnis auftreten.
Bewegungsmelder
Listing 1 implementiert diesen Bewegungsmelder mithilfe des Pakets fsnotify von Github. Unter Linux nutzt es die Inotify-Schnittstelle des Kernels, auf BSD Kqueue und sogar auf Windows funktioniert es mit einer exotischen Schnittstelle.
Dabei kommt Listing 1 objektorientiert daher. Es definiert in Zeile 7 eine Struktur, die den »fsnotify«-Watcher enthält. Wie in Go üblich gibt der Konstruktor »NewNotifier()« ab Zeile 10 einen Pointer auf die Struktur zurück, mit dem das Hauptprogramm später die folgenden Funktionen mittels des Receiver-Mechanismus als Methoden aufruft.
Listing 1
notify.go
package main
import (
"os"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
type Notifier struct {
watcher *fsnotify.Watcher
}
func NewNotifier() *Notifier {
return &Notifier{}
}
func (n *Notifier) Wait() {
select {
case <-n.watcher.Events:
case <-n.watcher.Errors:
}
}
func (n *Notifier) Start(rootDir string) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
panic(err)
}
watcher.Add(filepath.Join(gitTopDir(), ".git/HEAD"))
watcher.Add(filepath.Join(gitTopDir(), ".git/refs/remotes/origin"))
filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if filepath.Base(path) == ".git" {
return filepath.SkipDir // avoid .git loops
}
if err := watcher.Add(path); err != nil {
return err
}
}
return nil
})
n.watcher = watcher
}
Dabei bietet der Code nur zwei Funktionen: »Start()« zum Aufsetzen des Überwachers und »Wait()«, mit dem der Aufrufer seinen eigenen Code blockieren kann, bis sich etwas in den überwachten Verzeichnissen rührt.
Die Kernel-Schnittstelle zu Inotify überwacht allerdings nur einzelne Dateien oder Verzeichnisse, keine tiefen Verzeichnisbäume wie ein lokales Git-Repo. Deshalb instruiert die »Start()«-Funktion in Zeile 19 das Standardpaket »filepath.Walk()«, die Zweige des Dateibaums rekursiv zu durchwandern und bei jedem gefundenen Eintrag den angehängten Callback aufzurufen. Der übergibt Unterverzeichnisse mit »Add()« dem Watcher.
Richtig melden
Checkt der User eine Datei im Baum mit »git commit« ein, ändert sich an deren Inhalt gar nichts (Abbildung 2). Git merkt sich die Änderung aber im internen ».git«-Verzeichnis am Kopf des Repositorys. Wer nun auf die Idee kommt, den Inotifier einfach auf das ».git«-Verzeichnis anzusetzen, sieht sich einer Flut von Meldungen ausgesetzt: Jedes Mal, wenn »git status« läuft, orgelt Git auf der Datei ».git/index« herum. Hier hilft der Trick aus Zeile 24 vom Listing 1, explizit nur ».git/HEAD« zu überwachen: Der Wächter bekommt so ausschließlich Commits mit, denn »HEAD« wandert mit jeder eingecheckten Datei weiter zum letzten aktuellen Commit.
Ähnliches gilt für die Statusmeldung am unteren Ende des Gitwatch-Fensters in Abbildung 1: Sie zeigt an, wie weit der lokale Git-Tree schon vorgerückt ist, ohne dass die Remote-Seite die Änderungen mitbekommen hat. Führt der User »git push« aus, ändern sich keine lokalen Dateien; Git merkt sich lediglich den Stand der Remote-Seite im Branch unter ».git/refs/remotes/origin«. Übergibt man die dort liegende Branch-Datei ebenfalls dem Watcher, frischt Gitwatch die Anzeige nach einem erfolgreichen Push gleichfalls auf.
Möchte sich nun der Aufrufer des Notifiers schlafen legen, bis sich etwas im Repo rührt, ruft er »Wait()« ab Zeile 13 auf. Die Funktion hängt sich an beide Go-Channels des Fsnotify-Pakets, auf denen es festgestellte Änderungen herausschickt. Diese Meldungen enthalten sogar die Pfade der geänderten Dateien. Die interessieren »Wait()« aber gar nicht, da es bei Änderungen nur die Blockade aufgibt.
Git befragen
Niemand kann Fragen nach dem Stand eines Repos besser beantworten als Git selbst. Deshalb nutzt Listing 2 Git auf der Kommandozeile einer aufgespannten Shell, um herauszufinden, welche Dateien modifiziert, aber ohne Commit herumlungern.
Listing 2
git.go
package main
import (
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
)
func gitTopDir() string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
panic(err)
}
return strings.TrimSpace(string(out))
}
type FileStatus struct {
File string
Status string
}
func gitStatus() ([]FileStatus, error) {
cmd := exec.Command("git", "status", "--porcelain", ".")
out, err := cmd.Output()
if err != nil {
return nil, err
}
var statuses []FileStatus
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
blankRe := regexp.MustCompile(`\s+`)
for _, line := range lines {
parts := blankRe.Split(strings.TrimSpace(line), 2)
if len(parts) != 2 {
continue
}
statuses = append(statuses, FileStatus{
Status: parts[0],
File: parts[1],
})
}
return statuses, nil
}
func gitPushStatus() (string, error) {
counts := []int64{}
for _, head := range []string{"HEAD", "@{u}"} {
cmd := exec.Command("git", "rev-list", "--count", head)
out, err := cmd.Output()
if err != nil {
return "", err
}
numstr := strings.TrimSpace(string(out))
v, err := strconv.ParseInt(numstr, 10, 64)
if err != nil {
return "", err
}
counts = append(counts, v)
}
result := "Up-to-date with remote."
direction := "ahead"
diff := counts[0] - counts[1]
absdiff := diff
if diff < 0 {
direction = "behind"
absdiff = -diff
}
plural := ""
if absdiff > 1 {
plural = "s"
}
if diff != 0 {
result = fmt.Sprintf("[red]Local branch %s by %d commit%s.", direction, absdiff, plural)
}
return result, nil
}
Normalerweise legt »git status« nutzerfreundliche Schnörkel um die Ausgabe, deren Essenz sich später nur mit erheblichem Aufwand herausdestillieren lässt. Deshalb fügt das erste Kommando in Abbildung 3 die Option »–porcelain« hinzu, um eine maschinenlesbare Ausgabe zu erzeugen. Laut der Beteuerungen der Entwickler soll sie sich bei neuen Git-Versionen nicht ändern, im Gegensatz zur menschenlesbaren Ausgabe, die künftig vielleicht neue Schnörkel spendiert bekommt.
Ob der letzte Commit im Repo mit dem auf dem Remote-Server übereinstimmt oder man vielleicht vergessen hat, diese Änderungen hochzuspielen, finden die beiden letzten Kommandos in Abbildung 2 heraus. Das Git-Unterkommando »rev-list« wühlt sich durch die Commits im aktuellen Branch, »–count« ermittelt deren Gesamtzahl. Der Parameter »HEAD« bezieht sich auf den letzten Commit im aktuellen lokalen Branch, und das unförmige »@{u}« referenziert den des »upstream«-Branches, also normalerweise »origin/master« oder »origin/main«. Die Differenz der Zähler gibt an, ob der lokale Remote-Baum zurückhängt. Die Funktion »gitPushStatus()« ab Zeile 42 in Listing 2 modelliert das Ergebnis als leicht lesbaren String – inklusive der Pluralbildung bei »commit(s)«, denn nur Schluderer konfrontieren den Endanwender mit halbfertigen Programmen!
Nun läuft Gitwatch später nicht unbedingt am oberen Ende eines Git-Baums an. Oft soll es nur einen Teilbaum anzeigen, falls im sonstigen Repo (wie bei mir üblich) ein rechter Verhau herrscht. Diese Vorgehensweise ist durchaus legitim, aber um das interne Verzeichnis ».git« an der Spitze des Repos zu überwachen, muss »gitTopDir()« ab Zeile 09 in Listing 2 den absoluten Pfad dorthin ermitteln. Das erledigt das Kommando »git rev-parse –show-toplevel« zuverlässig, und die Funktion baut den Shell-Aufruf nur in Go ein. Dasselbe gilt für »gitStatus()« (Zeile 21) und »gitPushStatus()« (Zeile 42). Beide nutzen die Shell-Schnittstelle von Go in »os/exec« und fieseln die Essenz der Ausgaben mit String-Funktionen wie »TrimSpace()« und regulären Ausdrücken heraus.
Baum aus Knoten
Als Terminal-UI nutzt die Applikation das Paket tview auf Github, das auch bekannte Kommandozeilen-Tools wie die Kubernetes-CLI einsetzen. Listing 3 zieht für die Darstellung der Hierarchie lokal modifizierter Dateien die Komponente »TreeView« heran. Sie pinselt an der Spitze des Baums den Namen des Root-Knotens ins Terminal und malt dann mit senkrechten und waagerechten Strichmännchen die Verzeichnisse mit den relevanten Dateien.
Listing 3
tree.go
package main
import (
"strings"
"github.com/rivo/tview"
)
var statusColor = map[string]string{
"??": "[orange]",
"M": "[red]",
"MM": "[red]",
}
type Cmd struct {
fs []FileStatus
pstatus string
}
func ui() (*tview.Application, chan Cmd) {
app := tview.NewApplication()
root := mktree(gitTopDir(), []FileStatus{{File: "waah"}})
tree := tview.NewTreeView().SetRoot(root).SetCurrentNode(root)
pstatus := tview.NewTextView().SetDynamicColors(true)
layout := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(tree, 0, 1, true).
AddItem(pstatus, 1, 0, false)
cmds := make(chan Cmd)
go func() {
for {
cmd := <-cmds
pstatus.SetText(cmd.pstatus)
newroot := mktree(gitTopDir(), cmd.fs)
tree.SetRoot(newroot).SetCurrentNode(newroot)
app.QueueUpdateDraw(func() {})
}
}()
app.SetRoot(layout, true)
return app, cmds
}
func mktree(title string, entries []FileStatus) *tview.TreeNode {
root := tview.NewTreeNode(title)
nodeMap := map[string]*tview.TreeNode{"": root}
for _, entry := range entries {
parts := strings.Split(entry.File, "/")
path := ""
parent := root
for i, part := range parts {
if i > 0 {
path += "/"
}
path += part
if _, exists := nodeMap[path]; !exists {
color := ""
if i == len(parts)-1 {
color = statusColor[entry.Status]
}
node := tview.NewTreeNode(color + part)
nodeMap[path] = node
parent.AddChild(node)
}
parent = nodeMap[path]
}
}
return root
}
Objekt-Design oder Channel?
Zur Ansteuerung der Display-Komponente bieten sich nun zwei Ansätze an. Ein objektorientiertes Design böte Methoden an, die den Baum bei Änderungen neu zeichnen. Dabei behielte die Komponente den aktuellen Status in einer Instanzvariablen, und die Update-Methode griffe darauf zurück, um die grafischen Komponenten an die neuen Gegebenheiten anzupassen.
Stattdessen wirft Zeile 25 in Listing 3 eine nebenläufige Go-Routine mit Endlosschleife an, die in Zeile 27 in einem neu erzeugten Channel auf Kommandos wartet. Da die Funktion »ui()« den Channel am Ende an den Aufrufer zurückreicht, kann der später Nachrichten herunterschicken. Das betrifft einerseits solche bezüglich der lokal modifizierten Dateien (als Array von »FileStatus«-Strukturen) sowie andererseits den Push-Status bezüglich des Remote-Repos (als String im Attribut »pstatus« der »Cmd«-Struktur ab Zeile 11).
Zeile 27 schnappt sich also ankommende Kommandos aus dem Channel, Zeile 28 gibt den Push-Status des Repos an das »pstatus«-Widget zur Anzeige weiter. Zeile 29 feuert die ab Zeile 37 implementierte Funktion »mktree()« ab, die alle im Array »cmd.fs« liegenden Dateien als Baum anzeigt. Damit »tview« anschließend den geänderten Baum auf den Schirm bringt, benachrichtigt »app.QueueUpdateDraw()« in Zeile 31 die »tview«-Event-Schleife.
Bäume wachsen hören
Die Pfade der modifizierten Dateien kommen dementsprechend als Array von Strings an. Es bedarf etwas mentaler Gymnastik, um aus einer Liste wie »a/b«, »a/b/c«, »d« einen Baum zu zeichnen, der unter einem Ast »a/b« einen Eintrag »c« führt, während »d« wieder auf Root-Ebene liegt.
Zunächst spaltet Zeile 41 einen Pfad wie »a/b/c« in seine Komponenten. Die nachfolgende For-Schleife ab Zeile 44 iteriert über die Einträge, wobei die Variable »path« stets auf dem neuesten Stand des so weit abgearbeiteten Pfads bleibt. Jeden neuen Zweig des Baums erzeugt »NewTreeNode()« in Zeile 54 als Variable vom Typ »tview.TreeNode«.
Der Pointer »parent« zeigt auf den Elternzweig des aktuell abgearbeiteten Knotens. Neue Kinder hängt »AddChild()« in Zeile 56 in den Baum ein. Damit der Code nun schnell nachsehen kann, welches Node-Objekt zu einem Pfad wie »a/b/c« zuständig ist, führt er eine Hash-Tabelle »nodeMap«, die Node-Objekten String-Pfade zuweist.
Farblich ansprechend
Lokal modifizierte Dateien, die bereits als Vorgängerversion im Git-Tree liegen, soll der Baum rot darstellen. Die Hash-Map »statusColor« ab Zeile 6 weist dem von Git ausgegebenen Status (»M«) die Farbe Rot zu. Weitere Möglichkeiten sind »??« für lokale Dateien, für die sich (noch) kein Pendant in Git findet. Ein Sonderfall ist »MM« für Dateien, die teilweise im lokalen Git-Index und weiter im lokalen Verzeichnis modifiziert wurden. Auch sie erscheinen im Baum rot. Das Terminalgrafik-Paket »tview« färbt Einträge entsprechend ein, sofern deren Namen einen Marker wie »[red]« zur farblichen Kennzeichnung enthält.
Das Hauptprogramm in Listing 4 muss nur noch alle Komponenten vereinen und dirigieren. In einer nebenläufigen Go-Routine ab Zeile 18 tritt es in eine Endlosschleife ein, die den Git-Status abfragt und ihn anzeigt. Dann wartet »notify.Wait()« in Zeile 31 darauf, dass sich wieder etwas in den überwachten Verzeichnissen rührt. Derweil läuft die Terminal-UI nach »app.Run()« in Zeile 34 unbehelligt weiter, bis der User das Programm mit [Strg]+[C] abbricht.
Listing 4
gitwatch.go
package main
import (
"log"
"os"
)
func main() {
rootDir := "."
if len(os.Args) > 1 {
rootDir = os.Args[1]
}
err := os.Chdir(rootDir)
if err != nil {
panic(err)
}
notify := NewNotifier()
notify.Start(rootDir)
app, cmds := ui()
go func() {
for {
statuses, err := gitStatus()
if err != nil {
log.Printf("Status error: %v\n", err)
break
}
pstatus, err := gitPushStatus()
if err != nil {
log.Printf("Push status error: %v\n", err)
break
}
cmds <- Cmd{fs: statuses, pstatus: pstatus}
notify.Wait()
}
}()
if err := app.Run(); err != nil {
panic(err)
}
}
Vorsicht im Parallelbetrieb
Wer in Go mit nebenläufigen Goroutinen hantiert, sollte wissen, dass selbst interne Go-Datenstrukturen wie Array-Slices nicht gegen Race Conditions geschützt sind. Es gilt also, genau aufzupassen, ob sich nicht zwei Parallelläufer in die Quere kommen – etwa, weil einer einen Array-Slice verkürzt, während der andere liest. Das kann zu schweren Laufzeitfehlern und Datenkorruption führen. Treten derartige Szenarien auf, schützt der Einsatz eines Locks aus dem Paket sync.Mutex vor gleichzeitigem Zugriff. Dasselbe gilt für die grafische Darstellung mit tview.
Die vorliegende Applikation wählt den simplen Ansatz, den Baum bei jeder Änderung einfach komplett neu zu zeichnen, die Zugriffe erfolgen jeweils von nur einer Goroutine aus. Überprüfen lässt sich das mit der Compile-Option »-race«. Das Programm warnt dann zur Laufzeit, falls mehrere Goroutinen sich um eine Ressource reißen und der Zufall bestimmt, wer zuerst drankommt.
Listing 5
Binary erzeugen
$ go mod init gitwatch $ go mod tidy $ go build gitwatch.go notify.go tree.go git.go
Der Dreisatz aus Listing 5 schraubt die vier Listings dieser Ausgabe zu einem Binary zusammen, samt aller von Github eingeholten Abhängigkeiten. Aus dem aktuellen Arbeitsverzeichnis heraus aufgerufen und in einem Terminal an den Rand des Desktops gestellt, überwacht das Programm den lokalen Dateibaum und zeigt in Rot modifizierte Dateien an, die noch nicht der Versionskontrolle unterliegen. Neue, nicht eingecheckte Dateien erscheinen in Orange. Und dank des in der Fußzeile angezeigten Push-Status vergisst niemand mehr, versionierte Änderungen in die Cloud hochzuladen. Das schließt späteres Haareraufen zuverlässig aus. (uba/jlu)
Infos








