Architecture¶
git-repo-tracker is a Go repo-tracking backend with platform frontends. The primary desktop frontend is a Fyne v2 tray app; a headless CLI uses the same backend; and the optional GNOME Shell extension renders a separate GNOME-native panel frontend on top of the CLI boundary. State flows one way: discover → refresh → cache → notify the frontend.
For the user-facing split between macOS, native Linux, and GNOME, see Platform Integrations.
Layers¶
| Layer | Package(s) | Responsibility |
|---|---|---|
| Frontends | cmd/git-repo-tracker, cmd/git-repo-tracker-cli, internal/ui |
Fyne desktop app and headless CLI entrypoints |
| Optional integrations | integrations/gnome-shell |
GNOME Shell panel frontend; shells out to the CLI |
| Backend | internal/backend |
Shared service boundary over config + monitor for GUI, CLI, and future platform frontends |
| GUI | internal/ui |
Tray menu, search popover, settings window, theme, tooltips, click actions; macOS-native bits (cgo) |
| Daemon | internal/monitor |
Repo registry, the refresh schedulers, bounded worker pool, debounced change notifications, on-disk cache |
| API | internal/git, internal/scan |
Per-repo git operations (status/fetch/pull/diff); filesystem discovery |
| Persistence | internal/config |
YAML config — mutex-guarded, atomic writes, rollback on failure |
| Platform | internal/loginitem |
Launch-at-login (macOS LaunchAgent / Linux XDG autostart) |
cmd/git-repo-tracker wires version/flags → loads backend config → constructs ui.App → runs. On macOS that app adds a small Cocoa bridge for menu-bar behavior. On Linux it uses the native tray/AppIndicator path for a compact menu plus a full Fyne window. cmd/git-repo-tracker-cli uses internal/backend without importing Fyne. The GNOME Shell extension shells out to that CLI and renders GNOME-native panel menu widgets, which is how it can support richer panel UI without AppIndicator limits.
Layers communicate through a single onChange callback, not channels: the monitor calls onChange when state changes; the UI marshals that onto Fyne's main thread with fyne.Do. There are no channels between layers.
cmd/git-repo-tracker ─▶ ui.App ─▶ backend.Service ─▶ monitor.Manager ─▶ git / scan
▲ │ │
└── onChange ◀─┴────────────────────┘ (debounced; runs via fyne.Do)
cmd/git-repo-tracker-cli ─▶ backend.Service ─▶ monitor.Manager ─▶ git / scan
Data flow & threading¶
- Startup is cache-first.
monitor.Newloadsstate.jsoninto the in-memory registry, andStartfiresonChangeimmediately — so the tray/popover paint the last-known state before any git process runs. - Two schedulers run as background goroutines (
monitor.go): - localLoop (default every 30 s) — cheap
git status --porcelain=v2 --branchfor every repo: branch, upstream, ahead/behind, dirty counts. - remoteLoop (default every 30 min, or on demand) — re-discovers repos, then
git fetcheach fetch-eligible repo and recomputes behind + line stats. Both re-read their interval from config each cycle, so settings changes apply without a restart. - Bounded concurrency. A refresh feeds repo paths through a
jobschannel to a fixed pool of workers (refreshWorkers = 8), so scanning dozens of repos never forks hundreds of git processes at once. On cancellation the workers drain the channel and exit. - Debounced notifications. Per-repo updates call
notify(), which a singlenotifiergoroutine coalesces into at most oneonChangeper ~200 ms — a 70-repo refresh becomes a couple of UI rebuilds, not 70. - Locking. The registry map is guarded by a
sync.RWMutex. The UI only ever reads immutableSnapshot()copies; all UI view-state (maps, slices,popVisible, …) is touched only on the main thread.
The git layer (internal/git)¶
We shell out to the user's git binary rather than use a pure-Go library, so we inherit their SSH keys / credential helpers and match C-git's speed.
GetStatusruns onegit status --porcelain=v2 --branch— branch, upstream, ahead/behind (branch.ab), and per-file staged/modified/deleted/untracked counts, all from a single ~0 ms call.DiffStatuses a three-dot diff (HEAD...<ref>) — the merge-base→upstream change, i.e. exactly the lines you'd pull in.Fetchupdates remote-tracking refs only (never the working tree).Pullis--ff-only— it never merges or leaves a conflicted state. Repositories opted into Keep fresh are fetched during remote passes regardless of their root'sautoFetchsetting, then pulled when their refreshed status is behind.exec.goresolvesgitonce and augmentsPATH(/opt/homebrew/bin, …) plus setsGIT_TERMINAL_PROMPT=0, so background fetches fail fast instead of hanging on a credential prompt, even when launched from Finder/launchd.
Discovery (internal/scan)¶
Discover walks each root concurrently with filepath.WalkDir, pruning aggressively: it skips ignored directory names first, then on finding a .git entry records the repo and stops descending (so submodules / nested clones aren't double-counted), and honors a per-root depth limit.
Caching (internal/monitor/cache.go)¶
The discovered repos + last-known status are persisted as JSON in the OS cache dir (~/Library/Caches/git-repo-tracker/state.json). It's disposable — corrupt or version-mismatched cache is ignored and rebuilt. Writes use a unique os.CreateTemp + atomic rename, so the two refresh loops can both persist without clobbering a shared temp file.
Config (internal/config)¶
YAML, hand-editable, mutex-guarded. Mutations go through update(), which snapshots the persistable fields, applies the change, writes atomically, and rolls back the in-memory state if the write fails — so memory never diverges from disk. See CONFIGURATION.md.
UI specifics (internal/ui)¶
Fyne is great for cross-platform widgets but lacks a few things a menu-bar app wants; these are the non-obvious bits:
- Tray popover. A native tray menu can't host a text field, so the popover is a borderless splash window. Left-click toggles it (
systray.SetOnTapped), right-click shows the menu; the search field is the window's content. - Linux native tray. AppIndicator menus are intentionally kept limited: a snapshot of outdated repos plus one
Open Appitem that opens the real Fyne UI. Search, settings and config editing live in the app window rather than pretending the native menu can host them. - UI subpackages.
internal/uiowns Fyne app state and orchestration. Leaf UI concerns that do not need App state live in subpackages:internal/ui/actionsfor open-folder/editor/terminal commands andinternal/ui/trayiconfor tray icon resource rendering. - macOS native helpers (
native_darwin.go, cgo). Fyne exposes no window positioning, transparency, or focus-lost callback, so a small Cocoa shim: positions the popover under the cursor at the top of the screen, rounds the window corners (transparent corners via a content-layer mask + shadow), and observesNSApplicationDidResignActiveto dismiss the popover on click-away. The//exported Go callback lives innative_darwin_export.go(a cgo file using//exportmay only have declarations in its preamble).native_other.goprovides no-op stubs for non-macOS builds. - List rows (
repo_row.go) are a customwidget.BaseWidgetthat's bothHoverable(reveal action icons) andTappable(expand). The action icons are deliberately not Hoverable — the row hit-tests the pointer inMouseMovedto highlight them — otherwise moving onto a button would fire the row'sMouseOutand the buttons would vanish. Expansion grows the row vialist.SetItemHeight. - Marquee (
marquee.go) scrolls overflowing path/commit-message lines on hover by sliding a substring window — since it always renders a fitting substring, no clipping is needed. - Tooltips (
tooltip.go) are a non-intercepting overlay layered on top of the window content (Fyne 2.7 has no built-in tooltips, andwidget.PopUpwould capture clicks). - Theme (
theme.go) is a custom dark theme; the popover layers a gradient backdrop under the content to evoke a "glassy" look.
Build & release¶
Taskfile.yml—build,run,test,check(fmt+vet+test),bundle(macOS.app),icon(regenerateicon.pngfrom the SVG),linux-deps,release-snapshot..goreleaser.yaml— macOS arm64 + amd64 (CGO; amd64 cross-built viaclang -arch x86_64), lipo'd into one universal binary. The.appbundle and Homebrew cask are packaged by the release workflow (build/macos/), not GoReleaser (both are Pro-only there)..github/workflows—test-and-tag(test on macOS + Linux, then bump & push a SemVer tag from conventional commits) →release(GoReleaser universal binary +git-repo-tracker.appcask + a Linux amd64 archive).