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..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 unit). - On by default: the first run of an installed gitwatchd - 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 } @@ -444,7 +442,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,10 +456,10 @@ 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) + warn("daemon did not start; check " + logs) return 1 } fmt.Println("✓ daemon started") @@ -473,7 +471,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 +539,7 @@ func ensureDaemonRunning() { if os.Getenv("GITWATCHD_NO_SPAWN") != "" || isDaemonRunning() { return } - if unitInstalled() && systemctlPresent() { + if daemonServiceInstalled() && systemctlPresent() { systemctlUser("start", "gitwatchd") return } @@ -954,12 +952,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 +966,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 +975,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 +1027,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 +1054,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 +1071,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)") return 0 } @@ -1067,12 +1086,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 +1102,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 +1111,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 +1119,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 +1137,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 +1149,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 +1157,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 +1167,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 +1196,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.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 diff --git a/linux/daemon_test.go b/linux/daemon_test.go index c138134..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 { @@ -246,7 +262,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 +273,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 +288,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 +296,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 +313,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) } }