From 24ce0ef687b8967b8801292f5d6f54d554184202 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 18:13:39 +0000 Subject: [PATCH 1/4] Add a curl-to-sh installer and tag-triggered Linux release install.sh fetches the static Linux binary for the host arch, verifies it against the release SHA256SUMS (an unfetchable SHA256SUMS asks for confirmation and aborts after 30s without input), installs it into the first writable of /usr/local/bin, ~/.local/bin, ~/bin and starts it from there. release.yml cross-builds amd64 and arm64 on a v* tag and publishes both binaries plus SHA256SUMS. Autostart onboarding no longer cares where the binary lives: the first daemon start records the wish and enables the unit from any location, and a unit pointing at a binary other than the running one is rewritten on start. uninstall stays the one command that forgets everything. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 36 +++++++++ install.sh | 148 ++++++++++++++++++++++++++++++++++ linux/cli.go | 136 +++++++++++++++---------------- linux/cli_test.go | 59 +++++++------- linux/daemon_test.go | 51 ++---------- 5 files changed, 289 insertions(+), 141 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8cb9874 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v5 + with: + go-version-file: linux/go.mod + - name: Build linux binaries + working-directory: linux + run: | + mkdir -p ../dist + for arch in amd64 arm64; do + CGO_ENABLED=0 GOOS=linux GOARCH=$arch \ + go build -trimpath -ldflags "-s -w" -o "../dist/gitwatchd-linux-$arch" . + done + - name: Checksums + working-directory: dist + run: sha256sum gitwatchd-linux-amd64 gitwatchd-linux-arm64 > SHA256SUMS + - name: Create release + working-directory: dist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \ + --title "$GITHUB_REF_NAME" --generate-notes \ + gitwatchd-linux-amd64 gitwatchd-linux-arm64 SHA256SUMS diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ff90e29 --- /dev/null +++ b/install.sh @@ -0,0 +1,148 @@ +#!/bin/sh +# gitwatchd installer. Run with: +# curl -fsSL https://raw.githubusercontent.com/Will-Howard/gitwatchd/main/install.sh | sh + +set -u + +REPO=https://github.com/Will-Howard/gitwatchd + +say() { echo "$1" >&2; } +warn() { echo " ⚠ $1" >&2; } +err() { echo " ⚠ $1" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } +ensure() { "$@" || err "command failed: $*"; } + +cleanup() { + [ -n "${TMP:-}" ] && rm -rf "$TMP" + [ -n "${STAGED:-}" ] && rm -f "$STAGED" + return 0 +} + +detect_platform() { + os=$(uname -s) + arch=$(uname -m) + case "$arch" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + esac + if [ "$os" = Darwin ]; then + say "gitwatchd for macOS installs via Homebrew for the moment:" + say " brew tap will-howard/tap" + say " brew trust --cask will-howard/tap/gitwatchd" + say " brew install --cask gitwatchd" + exit 1 + fi + if [ "$os" = Linux ] && [ "$(getconf LONG_BIT 2>/dev/null || echo 64)" = 32 ]; then + err "gitwatchd needs a 64-bit userland, this system reports 32-bit." + fi + case "$os-$arch" in + Linux-amd64 | Linux-arm64) ARTIFACT="gitwatchd-linux-$arch" ;; + *) err "no gitwatchd build for $os-$arch yet. Please open an issue: $REPO/issues" ;; + esac +} + +resolve_source() { + if have curl; then + DOWNLOADER=curl + elif have wget; then + DOWNLOADER=wget + else + err "gitwatchd needs curl or wget to download itself. Install one and re-run." + fi + if [ -n "${GITWATCHD_DOWNLOAD_URL:-}" ]; then + BASE=${GITWATCHD_DOWNLOAD_URL%/} + elif [ -n "${GITWATCHD_VERSION:-}" ]; then + BASE="$REPO/releases/download/$GITWATCHD_VERSION" + else + BASE="$REPO/releases/latest/download" + fi +} + +download() { + if [ "$DOWNLOADER" = curl ]; then + curl -fsSL "$1" -o "$2" + else + wget -q -O "$2" "$1" + fi +} + +verify_checksum() { + if have sha256sum; then + got=$(sha256sum "$TMP/$ARTIFACT" | cut -d' ' -f1) + elif have shasum; then + got=$(shasum -a 256 "$TMP/$ARTIFACT" | cut -d' ' -f1) + else + warn "no sha256sum or shasum on this system, skipping checksum verification" + return 0 + fi + if ! download "$BASE/SHA256SUMS" "$TMP/SHA256SUMS"; then + warn "could not download $BASE/SHA256SUMS, so the binary cannot be verified" + say " press Enter to install it unverified, Ctrl-C to abort (30s)" + timeout 30 head -n1 /dev/tty >/dev/null 2>&1 || err "not confirmed, aborting" + return 0 + fi + want=$(awk -v a="$ARTIFACT" '$2 == a || $2 == "*" a {print $1}' "$TMP/SHA256SUMS") + [ -n "$want" ] || err "$ARTIFACT is not listed in SHA256SUMS" + if [ "$want" != "$got" ]; then + warn "checksum mismatch for $ARTIFACT, refusing to install:" + say " want $want" + say " got $got" + exit 1 + fi +} + +# Same search order as make install. +choose_dest() { + if [ -n "${GITWATCHD_INSTALL_DIR:-}" ]; then + ensure mkdir -p "$GITWATCHD_INSTALL_DIR" + DEST=${GITWATCHD_INSTALL_DIR%/} + return 0 + fi + for d in /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do + mkdir -p "$d" 2>/dev/null || true + if [ -w "$d" ]; then + DEST=$d + return 0 + fi + done + err "no writable bin dir found. Retry with GITWATCHD_INSTALL_DIR= set." +} + +install_binary() { + [ -x "$DEST/gitwatchd" ] && "$DEST/gitwatchd" stop >/dev/null 2>&1 + STAGED="$DEST/.gitwatchd.$$" + ensure cp "$TMP/$ARTIFACT" "$STAGED" + ensure chmod 0755 "$STAGED" + ensure mv -f "$STAGED" "$DEST/gitwatchd" + STAGED= + say "✓ binary → $DEST/gitwatchd" +} + +start_daemon() { + case ":$PATH:" in + *":$DEST:"*) ;; + *) warn "$DEST is not on your PATH. Add: export PATH=\"$DEST:\$PATH\"" ;; + esac + [ -n "${GITHUB_PATH:-}" ] && echo "$DEST" >>"$GITHUB_PATH" + "$DEST/gitwatchd" start || err "$DEST/gitwatchd start failed" + say " Next: gitwatchd . to watch the current repo" + say " gitwatchd status to see what it is doing" +} + +main() { + TMP= + STAGED= + trap cleanup EXIT + detect_platform + resolve_source + choose_dest + TMP=$(mktemp -d) || err "could not create a temporary directory" + download "$BASE/$ARTIFACT" "$TMP/$ARTIFACT" || + err "could not download $BASE/$ARTIFACT" + [ -s "$TMP/$ARTIFACT" ] || err "$BASE/$ARTIFACT downloaded empty" + verify_checksum + install_binary + start_daemon +} + +main "$@" diff --git a/linux/cli.go b/linux/cli.go index 8b0da31..1440ec8 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -40,8 +40,8 @@ COMMANDS status everything being watched, in the terminal start, stop start or stop the daemon autostart [on|off|status] - run the daemon at boot (installs a systemd user unit). - On by default: the first run of an installed gitwatchd + run the daemon at boot (installs a systemd user service). + On by default: the first run of the daemon turns it on. ` + "`gitwatchd autostart off`" + ` is the standing opt-out, and nothing turns it back on for you. config [path|edit] print the config file path, or open it in your @@ -444,7 +444,7 @@ func cliStart() int { fmt.Println("daemon already running") return 0 } - if unitInstalled() && systemctlPresent() { + if daemonServiceInstalled() && systemctlPresent() { if code, out := systemctlUser("start", "gitwatchd"); code != 0 { warn("systemctl --user start gitwatchd failed: " + out) return 1 @@ -458,7 +458,7 @@ func cliStart() int { } if !isDaemonRunning() { logs := logfilePath() - if unitInstalled() && systemctlPresent() { + if daemonServiceInstalled() && systemctlPresent() { logs = "journalctl --user -u gitwatchd" } warn("daemon did not come up; check " + logs) @@ -473,7 +473,7 @@ func cliStop() int { fmt.Println("daemon not running") return 0 } - if unitInstalled() && systemctlPresent() && unitActive() { + if daemonServiceInstalled() && systemctlPresent() && daemonServiceActive() { systemctlUser("stop", "gitwatchd") } else if pid := daemonPid(); pid > 0 { syscall.Kill(pid, syscall.SIGTERM) @@ -541,7 +541,7 @@ func ensureDaemonRunning() { if os.Getenv("GITWATCHD_NO_SPAWN") != "" || isDaemonRunning() { return } - if unitInstalled() && systemctlPresent() { + if daemonServiceInstalled() && systemctlPresent() { systemctlUser("start", "gitwatchd") return } @@ -954,12 +954,12 @@ func retryLine(lastTried time.Time, nextRetry *time.Time, now time.Time) string return tried + " · retrying in " + span(dt) } -// Autostart = a systemd user unit, the standard way for a per-user daemon to +// Autostart = a systemd user service, the standard way for a per-user daemon to // survive reboots and headless boots (with lingering). If systemd is absent, // report this and do nothing (the user should e.g. add `gitwatchd start` to // a startup script in this case). -func unitPath() string { +func daemonServicePath() string { return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") } @@ -968,8 +968,8 @@ func systemctlPresent() bool { return err == nil } -func unitInstalled() bool { - _, err := os.Stat(unitPath()) +func daemonServiceInstalled() bool { + _, err := os.Stat(daemonServicePath()) return err == nil } @@ -977,27 +977,48 @@ func systemctlUser(args ...string) (int, string) { return runCommand("systemctl", append([]string{"--user"}, args...), "") } -func unitActive() bool { +func daemonServiceActive() bool { _, out := systemctlUser("is-active", "gitwatchd") return out == "active" } -func unitEnabled() bool { - if !unitInstalled() { +func daemonServiceEnabled() bool { + if !daemonServiceInstalled() { return false } _, out := systemctlUser("is-enabled", "gitwatchd") return out == "enabled" } +// The service file hardcodes the path of the binary to start at boot; if that +// is not the binary running now (a deleted build, an old install dir), +// reconcile rewrites it. Compare against exactly what writeDaemonService writes. +func daemonServicePointsAtThisBinary() bool { + raw, err := os.ReadFile(daemonServicePath()) + if err != nil { + return false + } + exe, err := os.Executable() + if err != nil { + return true // cannot tell, so do not churn the service file + } + exe, _ = filepath.EvalSymlinks(exe) + for _, line := range strings.Split(string(raw), "\n") { + if target, ok := strings.CutPrefix(line, "ExecStart="); ok { + return target == exe+" daemon" + } + } + return false +} + // Returns "" on success, or why not. -func writeAutostartUnit() string { +func writeDaemonService() string { exe, err := os.Executable() if err != nil { return "cannot resolve the gitwatchd binary path: " + err.Error() } exe, _ = filepath.EvalSymlinks(exe) - unit := fmt.Sprintf(`[Unit] + service := fmt.Sprintf(`[Unit] Description=gitwatchd: watch git repos and auto-commit changes [Service] @@ -1008,11 +1029,11 @@ RestartSec=5 [Install] WantedBy=default.target `, exe) - if err := os.MkdirAll(filepath.Dir(unitPath()), 0o755); err != nil { - return "cannot create " + filepath.Dir(unitPath()) + ": " + err.Error() + if err := os.MkdirAll(filepath.Dir(daemonServicePath()), 0o755); err != nil { + return "cannot create " + filepath.Dir(daemonServicePath()) + ": " + err.Error() } - if err := os.WriteFile(unitPath(), []byte(unit), 0o644); err != nil { - return "cannot write " + unitPath() + ": " + err.Error() + if err := os.WriteFile(daemonServicePath(), []byte(service), 0o644); err != nil { + return "cannot write " + daemonServicePath() + ": " + err.Error() } systemctlUser("daemon-reload") return "" @@ -1035,13 +1056,13 @@ func autostartOn() int { } // Recorded before attempting, so a failed enable is retried on a later start. setLaunchAtLogin(true) - if msg := writeAutostartUnit(); msg != "" { + if msg := writeDaemonService(); msg != "" { warn(msg) return 1 } // A directly spawned daemon holds the single-instance lock and would - // make the unit fail; hand it over to systemd. - if isDaemonRunning() && !unitActive() { + // make the service fail; hand it over to systemd. + if isDaemonRunning() && !daemonServiceActive() { if pid := daemonPid(); pid > 0 { syscall.Kill(pid, syscall.SIGTERM) waitForDaemonExit(5 * time.Second) @@ -1052,12 +1073,12 @@ func autostartOn() int { return 1 } if note := enableLinger(); note != "" { - fmt.Println("✓ autostart: on (systemd user unit enabled)") + fmt.Println("✓ autostart: on (systemd user service enabled)") fmt.Println(" note: loginctl enable-linger failed (" + note + ")") fmt.Println(" without lingering the daemon stops when you log out") return 0 } - fmt.Println("✓ autostart: on (systemd user unit enabled, survives logout and reboot)") + fmt.Println("✓ autostart: on (systemd user service enabled, survives logout and reboot)") return 0 } @@ -1067,12 +1088,12 @@ func autostartOff() int { warn("systemd not found: nothing to turn off (autostart was never installed)") return 1 } - if !unitInstalled() { + if !daemonServiceInstalled() { fmt.Println("autostart: already off") return 0 } systemctlUser("disable", "--now", "gitwatchd") - os.Remove(unitPath()) + os.Remove(daemonServicePath()) systemctlUser("daemon-reload") fmt.Println("✓ autostart: off") return 0 @@ -1083,7 +1104,7 @@ func autostartStatus() int { fmt.Println("autostart: unavailable (systemd not found); run the daemon with: gitwatchd start") return 0 } - if !unitInstalled() { + if !daemonServiceInstalled() { fmt.Println("autostart: off") return 0 } @@ -1092,7 +1113,7 @@ func autostartStatus() int { if enabled != "enabled" { state = "installed but " + enabled } - if unitActive() { + if daemonServiceActive() { fmt.Printf("autostart: %s (daemon running)\n", state) } else { fmt.Printf("autostart: %s (daemon not running)\n", state) @@ -1100,29 +1121,12 @@ func autostartStatus() int { return 0 } -// First-run onboarding: an installed gitwatchd ends up running at boot without anyone asking. - -// A binary outside the three install destinations is a development copy: onboarding leaves it alone. -func isInstalledBinary(exe string) bool { - dir, err := filepath.EvalSymlinks(filepath.Dir(exe)) - if err != nil { - return false - } - for _, root := range []string{"/usr/local/bin", - filepath.Join(homeDir(), ".local", "bin"), filepath.Join(homeDir(), "bin")} { - if resolved, err := filepath.EvalSymlinks(root); err == nil && resolved == dir { - return true - } - } - return false -} - type autostartConditions struct { - installedBinary bool - recorded bool - wantsOn bool - systemdPresent bool - unitEnabled bool + recorded bool + wantsOn bool + systemdPresent bool + daemonServiceEnabled bool + serviceStale bool // enabled, but pointing at a binary that is not the one running } type autostartAction int @@ -1135,9 +1139,6 @@ const ( ) func autostartActionFor(c autostartConditions) autostartAction { - if !c.installedBinary { - return autostartLeaveAlone - } if c.recorded && !c.wantsOn { return autostartLeaveAlone // `autostart off` is never overridden } @@ -1150,7 +1151,7 @@ func autostartActionFor(c autostartConditions) autostartAction { if !c.recorded { return autostartEnableFirstRun } - if c.unitEnabled { + if c.daemonServiceEnabled && !c.serviceStale { return autostartLeaveAlone } return autostartReinstate @@ -1158,7 +1159,7 @@ func autostartActionFor(c autostartConditions) autostartAction { // No --now: the caller is the running daemon, and a second copy dies on the single-instance lock. func enableAutostartForNextBoot() string { - if msg := writeAutostartUnit(); msg != "" { + if msg := writeDaemonService(); msg != "" { return msg } if code, out := systemctlUser("enable", "gitwatchd"); code != 0 { @@ -1168,22 +1169,21 @@ func enableAutostartForNextBoot() string { return "" } -// First start of an installed gitwatchd turns autostart on: a daemon that does -// not come back after a reboot is not doing its one job. +// The first daemon start turns autostart on: a daemon that does not come back +// after a reboot is not doing its one job. Only the lock holder gets here, so +// the service always follows whichever binary actually has the daemon role. func reconcileAutostart() string { - exe, err := os.Executable() - if err != nil { - return "" - } wantsOn, recorded := launchAtLogin() conditions := autostartConditions{ - installedBinary: isInstalledBinary(exe), - recorded: recorded, - wantsOn: wantsOn, - systemdPresent: systemctlPresent(), + recorded: recorded, + wantsOn: wantsOn, + systemdPresent: systemctlPresent(), } if conditions.systemdPresent { - conditions.unitEnabled = unitEnabled() + conditions.daemonServiceEnabled = daemonServiceEnabled() + if conditions.daemonServiceEnabled { + conditions.serviceStale = !daemonServicePointsAtThisBinary() + } } action := autostartActionFor(conditions) if action == autostartLeaveAlone { @@ -1198,8 +1198,8 @@ func reconcileAutostart() string { return "autostart could not be enabled: " + msg } if action == autostartEnableFirstRun { - return "autostart: on (systemd user unit enabled on first run; " + + return "autostart: on (systemd user service enabled on first run; " + "turn it off with `gitwatchd autostart off`)" } - return "autostart: the systemd user unit had gone missing, re-enabled it" + return "autostart: the systemd user service was missing or stale; re-enabled it" } diff --git a/linux/cli_test.go b/linux/cli_test.go index 898ee96..a9d7b7b 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -302,37 +302,38 @@ func TestTokenizeRespectsQuotes(t *testing.T) { } // Autostart onboarding: what a daemon start does about autostart, given the -// recorded wish, where the binary lives and whether systemd is here. +// recorded wish, the daemon service's state and whether systemd is here. func TestAutostartDecisionTable(t *testing.T) { - installed := autostartConditions{installedBinary: true, systemdPresent: true} cases := []struct { what string conditions autostartConditions want autostartAction }{ - {"a development copy is never onboarded", - autostartConditions{systemdPresent: true}, autostartLeaveAlone}, - {"a development copy is left alone even with a wish on record", - autostartConditions{recorded: true, wantsOn: true, systemdPresent: true}, autostartLeaveAlone}, - {"the first installed run turns autostart on", - installed, autostartEnableFirstRun}, + {"the first run turns autostart on", + autostartConditions{systemdPresent: true}, autostartEnableFirstRun}, {"a wish that is already satisfied needs nothing", - autostartConditions{installedBinary: true, systemdPresent: true, - recorded: true, wantsOn: true, unitEnabled: true}, autostartLeaveAlone}, - {"a wanted unit that went missing is reinstated", - autostartConditions{installedBinary: true, systemdPresent: true, + autostartConditions{systemdPresent: true, + recorded: true, wantsOn: true, daemonServiceEnabled: true}, autostartLeaveAlone}, + {"a wanted service that went missing is reinstated", + autostartConditions{systemdPresent: true, recorded: true, wantsOn: true}, autostartReinstate}, + {"a service pointing at another binary is reinstated", + autostartConditions{systemdPresent: true, recorded: true, wantsOn: true, + daemonServiceEnabled: true, serviceStale: true}, autostartReinstate}, {"an opt-out is never overridden", - autostartConditions{installedBinary: true, systemdPresent: true, recorded: true}, + autostartConditions{systemdPresent: true, recorded: true}, autostartLeaveAlone}, - {"the first installed run without systemd says so", - autostartConditions{installedBinary: true}, autostartReportUnavailable}, + {"an opt-out beats a stale service", + autostartConditions{systemdPresent: true, recorded: true, + daemonServiceEnabled: true, serviceStale: true}, autostartLeaveAlone}, + {"the first run without systemd says so", + autostartConditions{}, autostartReportUnavailable}, {"without systemd it says so once, not on every start", - autostartConditions{installedBinary: true, recorded: true, wantsOn: true}, + autostartConditions{recorded: true, wantsOn: true}, autostartLeaveAlone}, {"an opt-out without systemd stays quiet", - autostartConditions{installedBinary: true, recorded: true}, autostartLeaveAlone}, + autostartConditions{recorded: true}, autostartLeaveAlone}, } for _, c := range cases { if got := autostartActionFor(c.conditions); got != c.want { @@ -363,19 +364,21 @@ func TestAutostartWishIsRecordedAsLaunchAtLogin(t *testing.T) { } } -func TestOnlyAnInstalledBinaryIsOnboarded(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - for _, dir := range []string{".local/bin", "bin", "build"} { - os.MkdirAll(filepath.Join(home, dir), 0o755) +func TestDaemonServiceFollowsTheRunningBinary(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if daemonServicePointsAtThisBinary() { + t.Error("no service file at all cannot point at this binary") } - for _, dir := range []string{".local/bin", "bin"} { - if !isInstalledBinary(filepath.Join(home, dir, "gitwatchd")) { - t.Errorf("%s is one of the install destinations", dir) - } + os.MkdirAll(filepath.Dir(daemonServicePath()), 0o755) + os.WriteFile(daemonServicePath(), []byte("[Service]\nExecStart=/somewhere/else/gitwatchd daemon\n"), 0o644) + if daemonServicePointsAtThisBinary() { + t.Error("a service file for another binary is stale") } - if isInstalledBinary(filepath.Join(home, "build", "gitwatchd")) { - t.Error("a build directory holds a development copy") + exe, _ := os.Executable() + exe, _ = filepath.EvalSymlinks(exe) + os.WriteFile(daemonServicePath(), []byte("[Service]\nExecStart="+exe+" daemon\n"), 0o644) + if !daemonServicePointsAtThisBinary() { + t.Error("a service file for the running binary is current") } } diff --git a/linux/daemon_test.go b/linux/daemon_test.go index c138134..9905c53 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -246,7 +246,7 @@ func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { t.Errorf("the message must explain and point to `gitwatchd start`:\n%s", out) } if _, err := os.Stat(filepath.Join(home, ".config", "systemd")); err == nil { - t.Error("no unit may be written when systemd is absent") + t.Error("no service file may be written when systemd is absent") } if entries, _ := os.ReadDir(home); len(entries) > 2 { // bin/ and nothing else unexpected names := []string{} @@ -257,7 +257,7 @@ func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { } } -func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { +func TestFirstDaemonRunOnboardsAutostart(t *testing.T) { if testBinary == "" { t.Fatal("test binary did not build") } @@ -272,45 +272,6 @@ func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { if code, out := runCLI(env, "start"); code != 0 { t.Fatalf("start: code=%d out=%s", code, out) } - // Reconcile runs before the first repo is watched, so this log line means it is done. - waitFor(t, 15*time.Second, "the daemon to watch the repo", func() bool { - return strings.Contains(daemonLogIn(home), "watching "+filepath.Base(repo.path)+" (") - }) - if strings.Contains(stateFileIn(home), "launch-at-login") { - t.Error("a development copy must record no wish") - } - if _, err := os.Stat(filepath.Join(home, ".config")); err == nil { - t.Error("a development copy must write no unit") - } - if code, out := runCLI(env, "stop"); code != 0 { - t.Fatalf("stop: code=%d out=%s", code, out) - } -} - -func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { - if testBinary == "" { - t.Fatal("test binary did not build") - } - home := t.TempDir() - env := envWithoutSystemctl(home) - t.Cleanup(func() { killDaemonIfRunning(home) }) - installed := filepath.Join(home, ".local", "bin", "gitwatchd") - os.MkdirAll(filepath.Dir(installed), 0o755) - binary, err := os.ReadFile(testBinary) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(installed, binary, 0o755); err != nil { - t.Fatal(err) - } - - repo := newTestRepo(t) - if code, out := runBinary(installed, env, "add", "-s", "0", repo.path); code != 0 { - t.Fatalf("add failed: %s", out) - } - if code, out := runBinary(installed, env, "start"); code != 0 { - t.Fatalf("start: code=%d out=%s", code, out) - } waitFor(t, 15*time.Second, "the recorded autostart wish", func() bool { return strings.Contains(stateFileIn(home), `"launch-at-login": "on"`) }) @@ -319,14 +280,14 @@ func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { t.Errorf("the log must explain and point at `gitwatchd start`:\n%s", log) } if _, err := os.Stat(filepath.Join(home, ".config", "systemd")); err == nil { - t.Error("no unit may be written when systemd is absent") + t.Error("no service file may be written when systemd is absent") } // The wish is on record now, so a second start has nothing to say. - if code, out := runBinary(installed, env, "stop"); code != 0 { + if code, out := runCLI(env, "stop"); code != 0 { t.Fatalf("stop: code=%d out=%s", code, out) } - if code, out := runBinary(installed, env, "start"); code != 0 { + if code, out := runCLI(env, "start"); code != 0 { t.Fatalf("second start: code=%d out=%s", code, out) } watched := "watching " + filepath.Base(repo.path) + " (" @@ -336,7 +297,7 @@ func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { if n := strings.Count(daemonLogIn(home), "systemd not found"); n != 1 { t.Errorf("autostart said it %d times; once is the whole point:\n%s", n, daemonLogIn(home)) } - if code, out := runBinary(installed, env, "stop"); code != 0 { + if code, out := runCLI(env, "stop"); code != 0 { t.Fatalf("second stop: code=%d out=%s", code, out) } } From 9fd590c5c8026f03144260cb91d4fddae48b6866 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Thu, 30 Jul 2026 10:26:19 +0000 Subject: [PATCH 2/4] Tighten the user-facing copy per review settle becomes debounce, 'local only' is cut, the autostart help drops to two lines, doctor stops naming a key file and matches mac on the unset SSH_AUTH_SOCK line, and two autostart/start messages lose their tails. Co-Authored-By: Claude Fable 5 --- linux/cli.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index 1440ec8..c0c4cfb 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -37,13 +37,11 @@ COMMANDS rm stop watching a repo pause stop watching temporarily; the repo stays listed resume start watching again and commit what piled up - status everything being watched, in the terminal + status show everything being watched, along with any errors start, stop start or stop the daemon autostart [on|off|status] - run the daemon at boot (installs a systemd user service). - On by default: the first run of the daemon - turns it on. ` + "`gitwatchd autostart off`" + ` is the - standing opt-out, and nothing turns it back on for you. + launch the daemon at boot (on by default on install). + This adds a systemd service. config [path|edit] print the config file path, or open it in your editor (the one ` + "`git commit`" + ` uses) help show this help @@ -228,15 +226,15 @@ func cliAdd(args []string) int { configAppend(strings.Join(quoted, " ")) ensureDaemonRunning() - pushNote := "local only" + pushNote := "" if spec.Remote != "" { branch := spec.Branch if branch == "" { branch = currentBranch(spec.WorkDir(), spec.GitDir) } - pushNote = "→ " + spec.Remote + "/" + branch + pushNote = "→ " + spec.Remote + "/" + branch + ", " } - fmt.Printf("✓ watching %s (%s) %s, settle %ds\n", spec.Name(), spec.Path, pushNote, int(spec.Settle)) + fmt.Printf("✓ watching %s (%s) %sdebounce %ds\n", spec.Name(), spec.Path, pushNote, int(spec.Settle)) fmt.Println(" gitwatchd status to see everything watched") return 0 } @@ -382,10 +380,10 @@ func cliDoctor() int { } fmt.Printf(" SSH_AUTH_SOCK present · %d %s in agent\n", n, keys) if n == 0 { - fmt.Println(" ⚠ no keys loaded: SSH pushes may fail. Add: ssh-add ~/.ssh/id_ed25519") + fmt.Println(" ⚠ no keys loaded: SSH pushes may fail. Add one with: ssh-add") } } else { - fmt.Println(" SSH_AUTH_SOCK (unset) ⚠ SSH pushes will fail from the daemon unless keys are unencrypted") + fmt.Println(" SSH_AUTH_SOCK (unset) ⚠ SSH pushes will fail from the daemon") } return 0 } @@ -461,7 +459,7 @@ func cliStart() int { if daemonServiceInstalled() && systemctlPresent() { logs = "journalctl --user -u gitwatchd" } - warn("daemon did not come up; check " + logs) + warn("daemon did not start; check " + logs) return 1 } fmt.Println("✓ daemon started") @@ -1078,7 +1076,7 @@ func autostartOn() int { fmt.Println(" without lingering the daemon stops when you log out") return 0 } - fmt.Println("✓ autostart: on (systemd user service enabled, survives logout and reboot)") + fmt.Println("✓ autostart: on (systemd user service enabled)") return 0 } From b12862557ca8c2b212de182c1171632927517794 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Thu, 30 Jul 2026 10:43:08 +0000 Subject: [PATCH 3/4] Dump the daemon log and process list when the e2e test fails CI's TestDaemonEndToEnd timeout discards the daemon log, which is the one thing that says how far the daemon got. Co-Authored-By: Claude Fable 5 --- linux/daemon_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/linux/daemon_test.go b/linux/daemon_test.go index 9905c53..dc9ffde 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -67,6 +67,22 @@ func TestDaemonEndToEnd(t *testing.T) { home := t.TempDir() env := isolatedEnv(home) t.Cleanup(func() { killDaemonIfRunning(home) }) + // Runs before the kill above (LIFO), so a wedged daemon is still alive to inspect. + t.Cleanup(func() { + if !t.Failed() { + return + } + t.Logf("daemon log:\n%s", daemonLogIn(home)) + _, ps := runCommand("ps", []string{"-ef"}, "") + var related []string + for _, l := range strings.Split(ps, "\n") { + if strings.Contains(l, "systemctl") || strings.Contains(l, "loginctl") || + strings.Contains(l, "gitwatchd") { + related = append(related, l) + } + } + t.Logf("related processes:\n%s", strings.Join(related, "\n")) + }) repo := newTestRepo(t) if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { From 58a9fe655ecda283017a0420e271165de89c382b Mon Sep 17 00:00:00 2001 From: Will Howard Date: Thu, 30 Jul 2026 10:49:31 +0000 Subject: [PATCH 4/4] Reconcile autostart only after every watcher is in place On a host with a live user bus (the CI runner; not this devbox), the systemd calls in reconcile take long enough that repo and config changes made during daemon startup landed before any inotify watch existed, and with no catch-up commit on start they were missed for good. Watch first, then reconcile. Co-Authored-By: Claude Fable 5 --- linux/daemon.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/linux/daemon.go b/linux/daemon.go index 5ff8891..0af1815 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -171,9 +171,6 @@ func runDaemon() int { }, } d.logf("gitwatchd %s: watching config %s", version, configPath()) - if msg := reconcileAutostart(); msg != "" { - d.logf("%s", msg) - } d.reloadConfig() configChanged := make(chan struct{}, 1) @@ -190,6 +187,13 @@ func runDaemon() int { } }() + // Last: reconcile talks to systemd, which can be slow, and repo or config + // changes made while nothing watches would be missed for good (there is + // no catch-up commit on start). + if msg := reconcileAutostart(); msg != "" { + d.logf("%s", msg) + } + signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) <-signals