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)
}
}