Als altgedienter Dinosaurier aus der Steinzeit der Datenverarbeitung bevorzuge ich es nach wie vor, Code und Text mittels Kommandozeilen-Tools zu schreiben. Schnell eine .go
-Datei mit vim
geändert, kompiliert und ausprobiert, da stünde eine IDE nur im Weg. Allerdings muss ich sagen, dass ich oft "git status" tippe, um festzustellen, ob auch alle Änderungen eingecheckt wurden und es ist sogar schon vorgekommen, dass ich vergessen habe, eine Datei in den Git-Baum einzubinden und mir dann auf Reisen mit dem dem Laptop die Haare darüber gerauft habe, dass eine Änderung nur auf dem Rechner daheim und nicht im Git-Repo war.
Also wäre es schön, den Zustand eines Git-Verzeichnisses schon während der Arbeit in Echtzeit zu sehen: Welche Dateien wurden modifiziert, welche neu erzeugt und fehlen noch im Baum? Einige handgestrickte Shell-Prompts könnten hier nachhelfen, aber wie wär's mit einem neuen Tool namens gitwatch
, das in einem separaten Terminal läuft, den lokalen Baum anzeigt, und dabei ratz-fatz auf Änderungen reagiert und einfärbt, als wäre es das reinste Hexenwerk (Abbildung 1)?
![]() |
Abbildung 1: Das Tool gitwatch zeigt modifizierte Dateien und den Push-Status in Echtzeit an. |
Wie bekommt nun das Tool Änderungen in den angezeigten Dateien mit, auf dass es flugs seine Anzeige auffrische? Im einfachsten Fall fragt es in regelmäßigen Abständen nach und erkundigt sich mittels git status
nach dem Stand der Dinge. Polling nennt das der anglophile Fachmann.
Doch der eingestellte zeitliche Abstand dieser Nachfragen hat tiefgreifende Konsequenzen. Holt das Tool zum Beispiel nur alle 60 Sekunden die neuesten Daten, 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 System-Resourcen, da sich bei den meisten Anfragen überhaupt nichts geändert hat.
Schlauer ist es da, einen Mechanismus zu aktivieren, der umgekehrt das Tool benachrichtigt, falls sich etwas im Dateisystem ändert. Hierzu bietet Linux standardmäßig unter POSIX den inotify-Mechanismus ([2]), der den Kernel dazu instruiert, Callback-Funktionen anzusteuern falls Änderungen in einem überwachten Verzeichnis auftreten.
Listing 1 implementiert diesen Bewegungsmelder mit Hilfe des Pakets fsnotify
von Github. Auf Linux nutzt es die inotify-Schnittstelle des Kernels, auf BSD kqueue und sogar auf Windows funktioniert's mit einer Exotenschnittstelle. Dabei kommt Listing 1 objektorientiert daher, 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 weiter unten folgenden Funktionen mittels des Receiver-Mechanismus als Methoden aufruft.
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.
01 package main 02 import ( 03 "os" 04 "path/filepath" 05 "github.com/fsnotify/fsnotify" 06 ) 07 type Notifier struct { 08 watcher *fsnotify.Watcher 09 } 10 func NewNotifier() *Notifier { 11 return &Notifier{} 12 } 13 func (n *Notifier) Wait() { 14 select { 15 case <-n.watcher.Events: 16 case <-n.watcher.Errors: 17 } 18 } 19 func (n *Notifier) Start(rootDir string) { 20 watcher, err := fsnotify.NewWatcher() 21 if err != nil { 22 panic(err) 23 } 24 watcher.Add(filepath.Join(gitTopDir(), ".git/refs/heads/master")) 25 watcher.Add(filepath.Join(gitTopDir(), ".git/refs/remotes/origin")) 26 filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { 27 if err != nil { 28 return err 29 } 30 if info.IsDir() { 31 if filepath.Base(path) == ".git" { 32 return filepath.SkipDir // avoid .git loops 33 } 34 if err := watcher.Add(path); err != nil { 35 return err 36 } 37 } 38 return nil 39 }) 40 n.watcher = watcher 41 }
Die Kernel-Schnittstelle zu inotify
kann allerdings nur einzelne Dateien oder Verzeichnisse überwachen, keine tiefen Verzeichnisbäume wie ein lokales Git-Repo. Deshalb instruiert die Start()
-Funktion in Zeile 19 das Standardpaket filepath.Walk()
dazu, die Zweige des Dateibaums rekursiv zu durchwandern und bei jedem gefundenen Eintrag den angehängten Callback aufzurufen, der Unterverzeichnisse mit Add()
dem Watcher übergibt.
Checkt der User eine Datei im Baum mit git commit
ein, ändert sich an deren Inhalt gar nichts. Git merkt sich die Änderung aber im internen .git
-Verzeichnis am Kopf des Repositories. Wer nun auf die Idee kommt, den inotify-er einfach auf das .git
-Verzeichnis anzusetzen, sieht sich einer Flut von Meldungen ausgesetzt, denn jedesmal, wenn git status
läuft, orgelt git
auf der Datei .git/index
herum. Mit dem Trick aus Zeile 24 in Listing 1, explizit nur .git/refs/heads/master
zu überwachen, bekommt der Überwacher nur Commits mit, denn Hauptzweig wandert mit jeder eingecheckten Datei weiter zum letzten aktuellen Commit.
Ähnliches gilt für die Status-Meldung am unteren Ende des gitwatch
-Fensters in Abbildung 1, die anzeigt, 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, nur git
merkt sich den Stand der Remote-Seite im Branch unter .git/refs/remotes/origin
. Wird auch die dort liegende Branch-Datei dem Watcher zur Überwachung übergeben, frischt gitwatch
die Anzeige nach einem erfolgreichen Push ebenfalls 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 dieses festgestellte Änderungen herausschickt. Diese Meldungen enthalten sogar die Pfade der geänderten Dateien, die Wait()
aber gar nicht interessieren, da es bei jeglichen Änderungen nur die Blockade aufgibt.
01 package main 02 import ( 03 "fmt" 04 "os/exec" 05 "regexp" 06 "strconv" 07 "strings" 08 ) 09 func gitTopDir() string { 10 cmd := exec.Command("git", "rev-parse", "--show-toplevel") 11 out, err := cmd.Output() 12 if err != nil { 13 panic(err) 14 } 15 return strings.TrimSpace(string(out)) 16 } 17 type FileStatus struct { 18 File string 19 Status string 20 } 21 func gitStatus() ([]FileStatus, error) { 22 cmd := exec.Command("git", "status", "--porcelain", ".") 23 out, err := cmd.Output() 24 if err != nil { 25 return nil, err 26 } 27 var statuses []FileStatus 28 lines := strings.Split(strings.TrimSpace(string(out)), "\n") 29 blankRe := regexp.MustCompile(`\s+`) 30 for _, line := range lines { 31 parts := blankRe.Split(strings.TrimSpace(line), 2) 32 if len(parts) != 2 { 33 continue 34 } 35 statuses = append(statuses, FileStatus{ 36 Status: parts[0], 37 File: parts[1], 38 }) 39 } 40 return statuses, nil 41 } 42 func gitPushStatus() (string, error) { 43 counts := []int64{} 44 for _, head := range []string{"HEAD", "origin/master"} { 45 cmd := exec.Command("git", "rev-list", "--count", head) 46 out, err := cmd.Output() 47 if err != nil { 48 return "", err 49 } 50 numstr := strings.TrimSpace(string(out)) 51 v, err := strconv.ParseInt(numstr, 10, 64) 52 if err != nil { 53 return "", err 54 } 55 counts = append(counts, v) 56 } 57 result := "Up-to-date with remote." 58 direction := "ahead" 59 diff := counts[0] - counts[1] 60 absdiff := diff 61 if diff < 0 { 62 direction = "behind" 63 absdiff = -diff 64 } 65 plural := "" 66 if absdiff > 1 { 67 plural = "s" 68 } 69 if diff != 0 { 70 result = fmt.Sprintf("[red]Local branch %s by %d commit%s.", direction, absdiff, plural) 71 } 72 return result, nil 73 }
Niemand kann Fragen nach dem Stand eines Repos besser beantworten als Git selbst, also nutzt Listing 2 git
auf der Kommandozeile einer aufgespannten Shell, um herauszufinden, welche Dateien modifiziert ohne Commit herumlungern. Da git status
normalerweise User-freundliche Schnörkel um die Ausgabe legt, deren Essenz sich später nur mit erheblichem Aufwand herausdestillieren lässt, fügt das erste Kommando in Abbildung 2 die Option --porcelain
hinzu, die eine maschinenlesbare Ausgabe erzeugt. Laut dem Treueschwur der Entwickler soll sie sich bei neuen Git-Versionen nicht ändern, im Gegensatz zur menschenlesbaren Ausgabe, die zukünftig vielleicht neue Schnörkel spendiert bekommt.
Ob der letzte Commit im Repo mit dem auf dem Remote-Server übereinstimmt, oder ob vielleicht vergessen wurde, diese Änderungen hochzuspielen, finden die beiden letzten Kommandos in Abbildung 2 heraus. Gits Unterkommando rev-list
wühlt sich durch die Commits im aktuellen Branch und --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 der Remote-Baum zurückliegt und 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 Enduser mit halbfertigen Programmen!
![]() |
Abbildung 2: Maschinenlesbare Ausgabe von git-Kommandos |
Nun läuft gitwatcher
später nicht unbedingt am oberen Ende eines Git-Baums an, sondern soll vielleicht 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. Selbiges gilt für gitStatus()
(Zeile 21) und gitPushStatus()
(Zeile 42), beide nutzen Gos Shell-Schnittstelle in os/exec
und fieseln die Essenz der Ausgaben mit String-Funktionen wie TrimSpace()
und regulären Ausdrücken heraus.
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, die an der Spitze des Baumes den Namen des Root-Knotens ins Text-Terminal pinselt und dann mit senkrechten und waagrechten Strichmännchen die Verzeichnisse mit den relevanten Dateien malt.
01 package main 02 import ( 03 "strings" 04 "github.com/rivo/tview" 05 ) 06 var statusColor = map[string]string{ 07 "??": "[orange]", 08 "M": "[red]", 09 "MM": "[red]", 10 } 11 type Cmd struct { 12 fs []FileStatus 13 pstatus string 14 } 15 func ui() (*tview.Application, chan Cmd) { 16 app := tview.NewApplication() 17 root := mktree(gitTopDir(), []FileStatus{}) 18 tree := tview.NewTreeView().SetRoot(root).SetCurrentNode(root) 19 pstatus := tview.NewTextView().SetDynamicColors(true) 20 layout := tview.NewFlex(). 21 SetDirection(tview.FlexRow). 22 AddItem(tree, 0, 1, true). 23 AddItem(pstatus, 1, 0, false) 24 cmds := make(chan Cmd) 25 go func() { 26 for { 27 cmd := <-cmds 28 pstatus.SetText(cmd.pstatus) 29 newroot := mktree(gitTopDir(), cmd.fs) 30 tree.SetRoot(newroot).SetCurrentNode(newroot) 31 app.QueueUpdateDraw(func() {}) 32 } 33 }() 34 app.SetRoot(layout, true) 35 return app, cmds 36 } 37 func mktree(title string, entries []FileStatus) *tview.TreeNode { 38 root := tview.NewTreeNode(title) 39 nodeMap := map[string]*tview.TreeNode{"": root} 40 for _, entry := range entries { 41 parts := strings.Split(entry.File, "/") 42 path := "" 43 parent := root 44 for i, part := range parts { 45 if i > 0 { 46 path += "/" 47 } 48 path += part 49 if _, exists := nodeMap[path]; !exists { 50 color := "" 51 if i == len(parts)-1 { 52 color = statusColor[entry.Status] 53 } 54 node := tview.NewTreeNode(color + part) 55 nodeMap[path] = node 56 parent.AddChild(node) 57 } 58 parent = nodeMap[path] 59 } 60 } 61 return root 62 }
Unten im Terminal-Fenster (Abbildung 1) findet sich noch ein TextView
-Widget, das den Push-Status des lokalen Git-Baums bezüglich der Remote einblendet. Das flexible Layout ab Zeile 20 in Listing 3 malt beide Widgets untereinander und erlaubt es dem oberen, sich nach Belieben vertikal auszustrecken.
Zur Ansteuerung der Display-Komponente bieten sich nun zwei Ansätze an: Objektorientiertes Design böte Methoden an, die den Baum bei bemerkten Ä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.
Statt dessen 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 dieser später Nachrichten herunterschicken, bezüglich der lokal modifizierten Dateien (als Array von FileStatus
-Strukturen) sowie den Push-Status bezüglich des Remote-Repos betreffend, 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, und 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 auch den geänderten Baum auf den Schirm bringt, benachrichtigt app.QueueUpdateDraw()
in Zeile 31 die tview
-Eventschleife.
Die Pfade der modifizierten Dateien kommen also als Array von Strings an, und um aus einer Liste wie zum Beispiel a/b
, a/b/c
, d
einen Baum zu zeichnen, der zum Beispiel unter einem Ast a/b
einen Eintrag c
führt, während d
wieder auf Root-Ebene liegt, ist etwas mentale Gymnastik erforderlich.
Erst 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 soweit 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 abgearbeitenen 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 zum Beispiel a/b/c
zuständig ist, führt er eine Hash-Tabelle nodeMap
, die String-Pfade Node-Objekten zuweist.
Lokal modifizierte Dateien, die bereits als Vorgängerversion im Git-Tree residieren, 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 Pedant in Git findet. Ein Sonderfall ist "MM" für Dateien, die teilweise im lokalen Git-Index modifiziert wurden und weiter im lokalen Verzeichnis. Auch sie malt der Baum rot. Das Terminal-Grafik-Paket tview
färbt Einträge entsprechend ein, falls deren Namen einen Marker wie [red]
zur roten Untermalung enthält. Praktisch!
01 package main 02 import ( 03 "log" 04 "os" 05 ) 06 func main() { 07 rootDir := "." 08 if len(os.Args) > 1 { 09 rootDir = os.Args[1] 10 } 11 err := os.Chdir(rootDir) 12 if err != nil { 13 panic(err) 14 } 15 notify := NewNotifier() 16 notify.Start(rootDir) 17 app, cmds := ui() 18 go func() { 19 for { 20 statuses, err := gitStatus() 21 if err != nil { 22 log.Printf("Status error: %v\n", err) 23 break 24 } 25 pstatus, err := gitPushStatus() 26 if err != nil { 27 log.Printf("Push status error: %v\n", err) 28 break 29 } 30 cmds <- Cmd{fs: statuses, pstatus: pstatus} 31 notify.Wait() 32 } 33 }() 34 if err := app.Run(); err != nil { 35 panic(err) 36 } 37 }
Das Hauptprogramm in Listing 4 muss nun 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 abfrägt, ihn anzeigt, und dann mit notify.Wait()
in Zeile 31 darauf wartet, 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 CTRL-c
abbricht.
Wer in Go übrigens mit nebenläufigen Goroutinen hantiert, sollte wissen, dass selbst Gos interne 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 können, zum Beispiel 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. Gleiches gilt für die grafische Darstellung mit tview
. Die vorliegende Applikation wählt den simplen Ansatz, den Baum bei jeglicher Änderung einfach ganz neu zu zeichnen, und die Zugriffe erfolgen jeweils von nur einer Goroutine aus. Überprüfen lässt sich das mit der Compile-Option -race
. Das fertige Binary warnt dann zur Laufzeit, falls mehrere Goroutinen sich um eine Resource reißen und der Zufall bestimmt, wer zuerst drankommt.
1 $ go mod init gitwatch 2 $ go mod tidy 3 $ go build gitwatch.go notify.go tree.go git.go
Die vier Listings dieser Ausgabe schraubt wie immer der Dreisatz aus Listing 5 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 es den lokalen Dateibaum und zeigt modifizierte Dateien in Rot an, die noch nicht der Versionskontrolle unterliegen. Neue, noch 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. Kein Haareraufen mehr, später!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/04/snapshot/
"inotify", https://en.wikipedia.org/wiki/Inotify
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc