From 079fe19dea0e38ea8f6b1104290d33deb256918d Mon Sep 17 00:00:00 2001 From: Will Howard Date: Tue, 28 Jul 2026 12:13:36 +0100 Subject: [PATCH 1/6] Move the macOS implementation into macos/ The repo now treats macOS as one implementation rather than the whole thing, making room for a Linux one beside it. The root Makefile picks the platform directory by uname and forwards every target, so make, make install and make test keep working from the root. Co-Authored-By: Claude Fable 5 --- Makefile | 158 ++---------------- macos/Makefile | 140 ++++++++++++++++ {Resources => macos/Resources}/Info.plist | 0 {Sources => macos/Sources}/CLI.swift | 0 {Sources => macos/Sources}/Config.swift | 0 {Sources => macos/Sources}/Git.swift | 0 {Sources => macos/Sources}/GitRuntime.swift | 0 .../Sources}/LaunchAtLogin.swift | 0 {Sources => macos/Sources}/RepoSpec.swift | 0 {Sources => macos/Sources}/RepoWatcher.swift | 0 {Sources => macos/Sources}/StateDB.swift | 0 {Sources => macos/Sources}/StatusFormat.swift | 0 {Sources => macos/Sources}/main.swift | 0 {Tests => macos/Tests}/CLITests.swift | 0 {Tests => macos/Tests}/ConfigTests.swift | 0 .../Tests}/FlagContractTests.swift | 0 {Tests => macos/Tests}/FormattingTests.swift | 0 {Tests => macos/Tests}/GitEngineTests.swift | 0 {Tests => macos/Tests}/Helpers.swift | 0 {Tests => macos/Tests}/ListChangesTests.swift | 0 {Tests => macos/Tests}/ParityTests.swift | 0 {Tests => macos/Tests}/Reference/README.md | 0 {Tests => macos/Tests}/Reference/gitwatch.sh | 0 {Tests => macos/Tests}/StateTests.swift | 0 {Tests => macos/Tests}/main.swift | 0 25 files changed, 158 insertions(+), 140 deletions(-) create mode 100644 macos/Makefile rename {Resources => macos/Resources}/Info.plist (100%) rename {Sources => macos/Sources}/CLI.swift (100%) rename {Sources => macos/Sources}/Config.swift (100%) rename {Sources => macos/Sources}/Git.swift (100%) rename {Sources => macos/Sources}/GitRuntime.swift (100%) rename {Sources => macos/Sources}/LaunchAtLogin.swift (100%) rename {Sources => macos/Sources}/RepoSpec.swift (100%) rename {Sources => macos/Sources}/RepoWatcher.swift (100%) rename {Sources => macos/Sources}/StateDB.swift (100%) rename {Sources => macos/Sources}/StatusFormat.swift (100%) rename {Sources => macos/Sources}/main.swift (100%) rename {Tests => macos/Tests}/CLITests.swift (100%) rename {Tests => macos/Tests}/ConfigTests.swift (100%) rename {Tests => macos/Tests}/FlagContractTests.swift (100%) rename {Tests => macos/Tests}/FormattingTests.swift (100%) rename {Tests => macos/Tests}/GitEngineTests.swift (100%) rename {Tests => macos/Tests}/Helpers.swift (100%) rename {Tests => macos/Tests}/ListChangesTests.swift (100%) rename {Tests => macos/Tests}/ParityTests.swift (100%) rename {Tests => macos/Tests}/Reference/README.md (100%) rename {Tests => macos/Tests}/Reference/gitwatch.sh (100%) rename {Tests => macos/Tests}/StateTests.swift (100%) rename {Tests => macos/Tests}/main.swift (100%) diff --git a/Makefile b/Makefile index f947398..5465b57 100644 --- a/Makefile +++ b/Makefile @@ -1,140 +1,18 @@ -# gitwatchd: Xcode-free build for a macOS menu-bar app. -# Compiles with swiftc and hand-assembles a .app bundle. No .xcodeproj, no Xcode.app. -# -# Tier 1: dev: -# make build build/gitwatchd.app -# make run build, then (re)launch from build/: the dev loop -# make stop kill any running instance -# make clean remove build artifacts -# -# Tier 2: local install (no sudo): -# make install → /Applications + CLI on PATH + launch; self-registers launch-at-login -# make uninstall remove app, CLI link, login item, and first-run state (re-onboards next install) -# -# Tier 3: distribution (needs a paid Apple Developer account): -# make sign-release DEV_ID="Developer ID Application: NAME (TEAMID)" -# make notarize NOTARY_PROFILE= - -APP_NAME := gitwatchd -BUNDLE_ID := com.gitwatchd.app -BUILD_DIR := build -APP_BUNDLE := $(BUILD_DIR)/$(APP_NAME).app -MACOS_DIR := $(APP_BUNDLE)/Contents/MacOS -RES_DIR := $(APP_BUNDLE)/Contents/Resources -BIN := $(MACOS_DIR)/$(APP_NAME) - -SOURCES := $(wildcard Sources/*.swift) -SWIFTC := swiftc -SWIFT_FLAGS := -O -framework AppKit -framework CoreServices -framework ServiceManagement - -# Tier 3 config (override on the command line): -DEV_ID ?= Developer ID Application: Will Howard (452SH7Q736) -NOTARY_PROFILE ?= gitwatchd-notary - -.PHONY: all run stop clean install uninstall sign-release notarize test - -all: $(APP_BUNDLE) - -$(APP_BUNDLE): $(SOURCES) Resources/Info.plist Makefile - @echo "→ assembling $(APP_BUNDLE)" - @mkdir -p $(MACOS_DIR) $(RES_DIR) - @cp Resources/Info.plist $(APP_BUNDLE)/Contents/Info.plist - @$(SWIFTC) $(SWIFT_FLAGS) $(SOURCES) -o $(BIN) - @# ad-hoc sign so the app has a stable identity (needed for launch-at-login) - @codesign --force --sign - $(APP_BUNDLE) 2>/dev/null || true - @echo "✓ built $(APP_BUNDLE)" - -# Tests: Swift Testing (@Test / #expect) via bare swiftc; still no SPM, no -# .xcodeproj. Engine + formatting sources compile together with Tests/ into one -# binary (Sources/main.swift is excluded: an app can't share top-level code -# with a test runner; Tests/main.swift hands the process to the test runner). -# Testing.framework ships with full Xcode, not the Command Line Tools, so this -# target needs Xcode installed; the app build itself does not. -TEST_BIN := $(BUILD_DIR)/gitwatchd-tests -TEST_SOURCES := $(filter-out Sources/main.swift,$(SOURCES)) $(wildcard Tests/*.swift) -PLATFORM_FWKS = $(shell xcrun --show-sdk-platform-path 2>/dev/null)/Developer/Library/Frameworks -SWIFT_PLUGINS = $(shell dirname $$(dirname $$(xcrun -f swiftc)))/lib/swift/host/plugins/testing - -test: - @test -d "$(PLATFORM_FWKS)/Testing.framework" || \ - { echo "✗ Testing.framework not found. Tests need full Xcode installed (the app build doesn't)."; exit 1; } - @echo "→ building tests" - @mkdir -p $(BUILD_DIR) - @$(SWIFTC) $(SWIFT_FLAGS) $(TEST_SOURCES) \ - -F "$(PLATFORM_FWKS)" \ - -plugin-path "$(SWIFT_PLUGINS)" \ - -Xlinker -rpath -Xlinker "$(PLATFORM_FWKS)" \ - -o $(TEST_BIN) - @$(TEST_BIN) - -run: stop all - @echo "→ launching $(APP_NAME) (dev, from build/)" - @open $(APP_BUNDLE) - -stop: - @pkill -x $(APP_NAME) 2>/dev/null || true - -# --- Tier 2: local install / uninstall --- -# Sudo-free: app → /Applications (or ~/Applications), CLI symlinked onto PATH, then -# launch: the daemon self-registers launch-at-login on the first run of an installed copy. - -install: all - @pkill -x $(APP_NAME) 2>/dev/null || true - @# App destination: prefer /Applications, fall back to ~/Applications. No sudo. - @if [ -w /Applications ]; then APP_DEST=/Applications; \ - else APP_DEST="$$HOME/Applications"; mkdir -p "$$APP_DEST"; fi; \ - rm -rf "$$APP_DEST/$(APP_NAME).app"; \ - cp -R "$(APP_BUNDLE)" "$$APP_DEST/"; \ - echo "✓ app → $$APP_DEST/$(APP_NAME).app"; \ - INNER="$$APP_DEST/$(APP_NAME).app/Contents/MacOS/$(APP_NAME)"; \ - CLI_DEST=""; \ - for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ - mkdir -p "$$d" 2>/dev/null || true; \ - if [ -w "$$d" ]; then CLI_DEST="$$d"; break; fi; \ - done; \ - if [ -n "$$CLI_DEST" ]; then \ - ln -sf "$$INNER" "$$CLI_DEST/$(APP_NAME)"; \ - echo "✓ cli → $$CLI_DEST/$(APP_NAME)"; \ - case ":$$PATH:" in *":$$CLI_DEST:"*) ;; \ - *) echo " ⚠ $$CLI_DEST is not on your PATH. Add: export PATH=\"$$CLI_DEST:\$$PATH\"";; esac; \ - else \ - echo " ⚠ no writable bin dir found. Link manually: ln -sf \"$$INNER\" /usr/local/bin/$(APP_NAME)"; \ - fi; \ - open "$$APP_DEST/$(APP_NAME).app" - @echo "✓ launched $(APP_NAME): menu-bar icon, top-right; starts at login (approve once in System Settings)" - @echo " Next: $(APP_NAME) . to watch the current repo" - -uninstall: - @pkill -x $(APP_NAME) 2>/dev/null || true - @# unregister launch-at-login from the installed bundle BEFORE deleting it - @for a in /Applications/$(APP_NAME).app "$$HOME/Applications/$(APP_NAME).app"; do \ - [ -x "$$a/Contents/MacOS/$(APP_NAME)" ] && "$$a/Contents/MacOS/$(APP_NAME)" autostart off >/dev/null 2>&1 || true; \ - done - @rm -rf /Applications/$(APP_NAME).app "$$HOME/Applications/$(APP_NAME).app" - @for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ - [ -L "$$d/$(APP_NAME)" ] && rm -f "$$d/$(APP_NAME)" && echo " removed $$d/$(APP_NAME)"; \ - done; true - @rm -rf "$$HOME/Library/Application Support/$(APP_NAME)" - @echo "✓ uninstalled: app, CLI link, login item, and first-run state cleared" - @echo " (config at ~/.$(APP_NAME) kept: 'rm ~/.$(APP_NAME)' to reset watched repos too)" - -# --- Tier 3: Developer ID sign + notarize (entirely CLI; no Xcode.app) --- - -sign-release: all - @test -n "$(DEV_ID)" || { echo "✗ set DEV_ID=\"Developer ID Application: NAME (TEAMID)\""; exit 1; } - @codesign --force --options runtime --timestamp --sign "$(DEV_ID)" $(APP_BUNDLE) - @codesign --verify --strict --verbose=2 $(APP_BUNDLE) - @echo "✓ Developer ID signed" - -notarize: sign-release - @ditto -c -k --keepParent $(APP_BUNDLE) $(BUILD_DIR)/$(APP_NAME)-notarize.zip - @xcrun notarytool submit $(BUILD_DIR)/$(APP_NAME)-notarize.zip --keychain-profile "$(NOTARY_PROFILE)" --wait - @# staple the .app, THEN zip the stapled app (a zip itself can't be stapled) - @xcrun stapler staple $(APP_BUNDLE) - @ditto -c -k --keepParent $(APP_BUNDLE) $(BUILD_DIR)/$(APP_NAME).zip - @shasum -a 256 $(BUILD_DIR)/$(APP_NAME).zip - @echo "✓ notarized + stapled → $(BUILD_DIR)/$(APP_NAME).zip" - -clean: - @rm -rf $(BUILD_DIR) - @echo "✓ cleaned" +# gitwatchd: one implementation per platform, in macos/ and linux/. +# This Makefile picks the one for the current platform and forwards every +# target to it. Run make inside a platform directory to be explicit. + +UNAME := $(shell uname -s) +ifeq ($(UNAME),Darwin) +PLATFORM := macos +else ifeq ($(UNAME),Linux) +PLATFORM := linux +else +$(error unsupported platform: $(UNAME)) +endif + +TARGETS := all run stop clean install uninstall test sign-release notarize + +.PHONY: $(TARGETS) +$(TARGETS): + @$(MAKE) -C $(PLATFORM) $@ diff --git a/macos/Makefile b/macos/Makefile new file mode 100644 index 0000000..f947398 --- /dev/null +++ b/macos/Makefile @@ -0,0 +1,140 @@ +# gitwatchd: Xcode-free build for a macOS menu-bar app. +# Compiles with swiftc and hand-assembles a .app bundle. No .xcodeproj, no Xcode.app. +# +# Tier 1: dev: +# make build build/gitwatchd.app +# make run build, then (re)launch from build/: the dev loop +# make stop kill any running instance +# make clean remove build artifacts +# +# Tier 2: local install (no sudo): +# make install → /Applications + CLI on PATH + launch; self-registers launch-at-login +# make uninstall remove app, CLI link, login item, and first-run state (re-onboards next install) +# +# Tier 3: distribution (needs a paid Apple Developer account): +# make sign-release DEV_ID="Developer ID Application: NAME (TEAMID)" +# make notarize NOTARY_PROFILE= + +APP_NAME := gitwatchd +BUNDLE_ID := com.gitwatchd.app +BUILD_DIR := build +APP_BUNDLE := $(BUILD_DIR)/$(APP_NAME).app +MACOS_DIR := $(APP_BUNDLE)/Contents/MacOS +RES_DIR := $(APP_BUNDLE)/Contents/Resources +BIN := $(MACOS_DIR)/$(APP_NAME) + +SOURCES := $(wildcard Sources/*.swift) +SWIFTC := swiftc +SWIFT_FLAGS := -O -framework AppKit -framework CoreServices -framework ServiceManagement + +# Tier 3 config (override on the command line): +DEV_ID ?= Developer ID Application: Will Howard (452SH7Q736) +NOTARY_PROFILE ?= gitwatchd-notary + +.PHONY: all run stop clean install uninstall sign-release notarize test + +all: $(APP_BUNDLE) + +$(APP_BUNDLE): $(SOURCES) Resources/Info.plist Makefile + @echo "→ assembling $(APP_BUNDLE)" + @mkdir -p $(MACOS_DIR) $(RES_DIR) + @cp Resources/Info.plist $(APP_BUNDLE)/Contents/Info.plist + @$(SWIFTC) $(SWIFT_FLAGS) $(SOURCES) -o $(BIN) + @# ad-hoc sign so the app has a stable identity (needed for launch-at-login) + @codesign --force --sign - $(APP_BUNDLE) 2>/dev/null || true + @echo "✓ built $(APP_BUNDLE)" + +# Tests: Swift Testing (@Test / #expect) via bare swiftc; still no SPM, no +# .xcodeproj. Engine + formatting sources compile together with Tests/ into one +# binary (Sources/main.swift is excluded: an app can't share top-level code +# with a test runner; Tests/main.swift hands the process to the test runner). +# Testing.framework ships with full Xcode, not the Command Line Tools, so this +# target needs Xcode installed; the app build itself does not. +TEST_BIN := $(BUILD_DIR)/gitwatchd-tests +TEST_SOURCES := $(filter-out Sources/main.swift,$(SOURCES)) $(wildcard Tests/*.swift) +PLATFORM_FWKS = $(shell xcrun --show-sdk-platform-path 2>/dev/null)/Developer/Library/Frameworks +SWIFT_PLUGINS = $(shell dirname $$(dirname $$(xcrun -f swiftc)))/lib/swift/host/plugins/testing + +test: + @test -d "$(PLATFORM_FWKS)/Testing.framework" || \ + { echo "✗ Testing.framework not found. Tests need full Xcode installed (the app build doesn't)."; exit 1; } + @echo "→ building tests" + @mkdir -p $(BUILD_DIR) + @$(SWIFTC) $(SWIFT_FLAGS) $(TEST_SOURCES) \ + -F "$(PLATFORM_FWKS)" \ + -plugin-path "$(SWIFT_PLUGINS)" \ + -Xlinker -rpath -Xlinker "$(PLATFORM_FWKS)" \ + -o $(TEST_BIN) + @$(TEST_BIN) + +run: stop all + @echo "→ launching $(APP_NAME) (dev, from build/)" + @open $(APP_BUNDLE) + +stop: + @pkill -x $(APP_NAME) 2>/dev/null || true + +# --- Tier 2: local install / uninstall --- +# Sudo-free: app → /Applications (or ~/Applications), CLI symlinked onto PATH, then +# launch: the daemon self-registers launch-at-login on the first run of an installed copy. + +install: all + @pkill -x $(APP_NAME) 2>/dev/null || true + @# App destination: prefer /Applications, fall back to ~/Applications. No sudo. + @if [ -w /Applications ]; then APP_DEST=/Applications; \ + else APP_DEST="$$HOME/Applications"; mkdir -p "$$APP_DEST"; fi; \ + rm -rf "$$APP_DEST/$(APP_NAME).app"; \ + cp -R "$(APP_BUNDLE)" "$$APP_DEST/"; \ + echo "✓ app → $$APP_DEST/$(APP_NAME).app"; \ + INNER="$$APP_DEST/$(APP_NAME).app/Contents/MacOS/$(APP_NAME)"; \ + CLI_DEST=""; \ + for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + mkdir -p "$$d" 2>/dev/null || true; \ + if [ -w "$$d" ]; then CLI_DEST="$$d"; break; fi; \ + done; \ + if [ -n "$$CLI_DEST" ]; then \ + ln -sf "$$INNER" "$$CLI_DEST/$(APP_NAME)"; \ + echo "✓ cli → $$CLI_DEST/$(APP_NAME)"; \ + case ":$$PATH:" in *":$$CLI_DEST:"*) ;; \ + *) echo " ⚠ $$CLI_DEST is not on your PATH. Add: export PATH=\"$$CLI_DEST:\$$PATH\"";; esac; \ + else \ + echo " ⚠ no writable bin dir found. Link manually: ln -sf \"$$INNER\" /usr/local/bin/$(APP_NAME)"; \ + fi; \ + open "$$APP_DEST/$(APP_NAME).app" + @echo "✓ launched $(APP_NAME): menu-bar icon, top-right; starts at login (approve once in System Settings)" + @echo " Next: $(APP_NAME) . to watch the current repo" + +uninstall: + @pkill -x $(APP_NAME) 2>/dev/null || true + @# unregister launch-at-login from the installed bundle BEFORE deleting it + @for a in /Applications/$(APP_NAME).app "$$HOME/Applications/$(APP_NAME).app"; do \ + [ -x "$$a/Contents/MacOS/$(APP_NAME)" ] && "$$a/Contents/MacOS/$(APP_NAME)" autostart off >/dev/null 2>&1 || true; \ + done + @rm -rf /Applications/$(APP_NAME).app "$$HOME/Applications/$(APP_NAME).app" + @for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + [ -L "$$d/$(APP_NAME)" ] && rm -f "$$d/$(APP_NAME)" && echo " removed $$d/$(APP_NAME)"; \ + done; true + @rm -rf "$$HOME/Library/Application Support/$(APP_NAME)" + @echo "✓ uninstalled: app, CLI link, login item, and first-run state cleared" + @echo " (config at ~/.$(APP_NAME) kept: 'rm ~/.$(APP_NAME)' to reset watched repos too)" + +# --- Tier 3: Developer ID sign + notarize (entirely CLI; no Xcode.app) --- + +sign-release: all + @test -n "$(DEV_ID)" || { echo "✗ set DEV_ID=\"Developer ID Application: NAME (TEAMID)\""; exit 1; } + @codesign --force --options runtime --timestamp --sign "$(DEV_ID)" $(APP_BUNDLE) + @codesign --verify --strict --verbose=2 $(APP_BUNDLE) + @echo "✓ Developer ID signed" + +notarize: sign-release + @ditto -c -k --keepParent $(APP_BUNDLE) $(BUILD_DIR)/$(APP_NAME)-notarize.zip + @xcrun notarytool submit $(BUILD_DIR)/$(APP_NAME)-notarize.zip --keychain-profile "$(NOTARY_PROFILE)" --wait + @# staple the .app, THEN zip the stapled app (a zip itself can't be stapled) + @xcrun stapler staple $(APP_BUNDLE) + @ditto -c -k --keepParent $(APP_BUNDLE) $(BUILD_DIR)/$(APP_NAME).zip + @shasum -a 256 $(BUILD_DIR)/$(APP_NAME).zip + @echo "✓ notarized + stapled → $(BUILD_DIR)/$(APP_NAME).zip" + +clean: + @rm -rf $(BUILD_DIR) + @echo "✓ cleaned" diff --git a/Resources/Info.plist b/macos/Resources/Info.plist similarity index 100% rename from Resources/Info.plist rename to macos/Resources/Info.plist diff --git a/Sources/CLI.swift b/macos/Sources/CLI.swift similarity index 100% rename from Sources/CLI.swift rename to macos/Sources/CLI.swift diff --git a/Sources/Config.swift b/macos/Sources/Config.swift similarity index 100% rename from Sources/Config.swift rename to macos/Sources/Config.swift diff --git a/Sources/Git.swift b/macos/Sources/Git.swift similarity index 100% rename from Sources/Git.swift rename to macos/Sources/Git.swift diff --git a/Sources/GitRuntime.swift b/macos/Sources/GitRuntime.swift similarity index 100% rename from Sources/GitRuntime.swift rename to macos/Sources/GitRuntime.swift diff --git a/Sources/LaunchAtLogin.swift b/macos/Sources/LaunchAtLogin.swift similarity index 100% rename from Sources/LaunchAtLogin.swift rename to macos/Sources/LaunchAtLogin.swift diff --git a/Sources/RepoSpec.swift b/macos/Sources/RepoSpec.swift similarity index 100% rename from Sources/RepoSpec.swift rename to macos/Sources/RepoSpec.swift diff --git a/Sources/RepoWatcher.swift b/macos/Sources/RepoWatcher.swift similarity index 100% rename from Sources/RepoWatcher.swift rename to macos/Sources/RepoWatcher.swift diff --git a/Sources/StateDB.swift b/macos/Sources/StateDB.swift similarity index 100% rename from Sources/StateDB.swift rename to macos/Sources/StateDB.swift diff --git a/Sources/StatusFormat.swift b/macos/Sources/StatusFormat.swift similarity index 100% rename from Sources/StatusFormat.swift rename to macos/Sources/StatusFormat.swift diff --git a/Sources/main.swift b/macos/Sources/main.swift similarity index 100% rename from Sources/main.swift rename to macos/Sources/main.swift diff --git a/Tests/CLITests.swift b/macos/Tests/CLITests.swift similarity index 100% rename from Tests/CLITests.swift rename to macos/Tests/CLITests.swift diff --git a/Tests/ConfigTests.swift b/macos/Tests/ConfigTests.swift similarity index 100% rename from Tests/ConfigTests.swift rename to macos/Tests/ConfigTests.swift diff --git a/Tests/FlagContractTests.swift b/macos/Tests/FlagContractTests.swift similarity index 100% rename from Tests/FlagContractTests.swift rename to macos/Tests/FlagContractTests.swift diff --git a/Tests/FormattingTests.swift b/macos/Tests/FormattingTests.swift similarity index 100% rename from Tests/FormattingTests.swift rename to macos/Tests/FormattingTests.swift diff --git a/Tests/GitEngineTests.swift b/macos/Tests/GitEngineTests.swift similarity index 100% rename from Tests/GitEngineTests.swift rename to macos/Tests/GitEngineTests.swift diff --git a/Tests/Helpers.swift b/macos/Tests/Helpers.swift similarity index 100% rename from Tests/Helpers.swift rename to macos/Tests/Helpers.swift diff --git a/Tests/ListChangesTests.swift b/macos/Tests/ListChangesTests.swift similarity index 100% rename from Tests/ListChangesTests.swift rename to macos/Tests/ListChangesTests.swift diff --git a/Tests/ParityTests.swift b/macos/Tests/ParityTests.swift similarity index 100% rename from Tests/ParityTests.swift rename to macos/Tests/ParityTests.swift diff --git a/Tests/Reference/README.md b/macos/Tests/Reference/README.md similarity index 100% rename from Tests/Reference/README.md rename to macos/Tests/Reference/README.md diff --git a/Tests/Reference/gitwatch.sh b/macos/Tests/Reference/gitwatch.sh similarity index 100% rename from Tests/Reference/gitwatch.sh rename to macos/Tests/Reference/gitwatch.sh diff --git a/Tests/StateTests.swift b/macos/Tests/StateTests.swift similarity index 100% rename from Tests/StateTests.swift rename to macos/Tests/StateTests.swift diff --git a/Tests/main.swift b/macos/Tests/main.swift similarity index 100% rename from Tests/main.swift rename to macos/Tests/main.swift From e627aee3a5402cf400960b96b04f0e33b8d77b94 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Tue, 28 Jul 2026 12:13:36 +0100 Subject: [PATCH 2/6] Add the Linux build skeleton and CI job A Go module in linux/ with the same make targets as macos/. The binary just prints "not implemented on Linux yet" and exits 1; the real behaviour lands incrementally on top. CI now builds and tests both platforms. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yml | 17 ++++++++++++++--- linux/Makefile | 20 ++++++++++++++++++++ linux/go.mod | 3 +++ linux/main.go | 11 +++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 linux/Makefile create mode 100644 linux/go.mod create mode 100644 linux/main.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e33f67..a8df946 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,11 +6,22 @@ on: pull_request: jobs: - test: + mac: runs-on: macos-15 steps: - uses: actions/checkout@v5 - name: Build app - run: make + run: make -C macos - name: Run tests - run: make test + run: make -C macos test + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v5 + with: + go-version-file: linux/go.mod + - name: Build + run: make -C linux + - name: Run tests + run: make -C linux test diff --git a/linux/Makefile b/linux/Makefile new file mode 100644 index 0000000..81d26dd --- /dev/null +++ b/linux/Makefile @@ -0,0 +1,20 @@ +# gitwatchd for Linux: a single static Go binary. +# make build build/gitwatchd for the host platform +# make test go vet + go test +# make clean remove build artifacts + +GOFLAGS := -trimpath -ldflags "-s -w" + +.PHONY: all build test clean + +all: build + +build: + CGO_ENABLED=0 go build $(GOFLAGS) -o build/gitwatchd . + +test: + go vet ./... + go test ./... + +clean: + rm -rf build diff --git a/linux/go.mod b/linux/go.mod new file mode 100644 index 0000000..f60e9f5 --- /dev/null +++ b/linux/go.mod @@ -0,0 +1,3 @@ +module gitwatchd + +go 1.24 diff --git a/linux/main.go b/linux/main.go new file mode 100644 index 0000000..0d89a27 --- /dev/null +++ b/linux/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "gitwatchd: not implemented on Linux yet") + os.Exit(1) +} From 5dc0fa2f8b47f85bc95ab7aeac20f714247a57fb Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 16:45:35 +0100 Subject: [PATCH 3/6] Linux daemon: support everything except -l/-L and -c/-C (#6) --- .gitignore | 3 + linux/Makefile | 74 +- linux/cli.go | 1168 +++++++++++++++++ linux/cli_test.go | 672 ++++++++++ linux/daemon.go | 613 +++++++++ linux/daemon_test.go | 569 ++++++++ linux/git_driver.go | 247 ++++ linux/git_driver_test.go | 512 ++++++++ linux/gitwatch_parity_test.go | 316 +++++ linux/main.go | 11 - linux/utils.go | 40 + macos/Tests/ParityTests.swift | 2 +- {macos/Tests/Reference => oracle}/README.md | 5 +- {macos/Tests/Reference => oracle}/gitwatch.sh | 0 14 files changed, 4211 insertions(+), 21 deletions(-) create mode 100644 linux/cli.go create mode 100644 linux/cli_test.go create mode 100644 linux/daemon.go create mode 100644 linux/daemon_test.go create mode 100644 linux/git_driver.go create mode 100644 linux/git_driver_test.go create mode 100644 linux/gitwatch_parity_test.go delete mode 100644 linux/main.go create mode 100644 linux/utils.go rename {macos/Tests/Reference => oracle}/README.md (70%) rename {macos/Tests/Reference => oracle}/gitwatch.sh (100%) diff --git a/.gitignore b/.gitignore index 41e1cd9..3c464fc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ NOTES.md # Code-signing material (private key + CSR); never part of this repo signing/ + +# Go binary built in-place by `go build .` in linux/ (make builds into build/) +/linux/gitwatchd diff --git a/linux/Makefile b/linux/Makefile index 81d26dd..79f1a5e 100644 --- a/linux/Makefile +++ b/linux/Makefile @@ -1,20 +1,82 @@ # gitwatchd for Linux: a single static Go binary. -# make build build/gitwatchd for the host platform -# make test go vet + go test -# make clean remove build artifacts +# make build build/gitwatchd for the host platform +# make test go vet + go test +# make install put the binary on PATH and start the daemon (no sudo) +# make uninstall remove the binary, systemd unit and daemon state +# make clean remove build artifacts GOFLAGS := -trimpath -ldflags "-s -w" +BUILD := build -.PHONY: all build test clean +# Install destination; empty means the first writable of /usr/local/bin, +# ~/.local/bin, ~/bin. Override as `make install DEST=/custom/bin`. +DEST ?= + +.PHONY: all build test install uninstall clean all: build build: - CGO_ENABLED=0 go build $(GOFLAGS) -o build/gitwatchd . + CGO_ENABLED=0 go build $(GOFLAGS) -o $(BUILD)/gitwatchd . test: go vet ./... go test ./... +# --- local install / uninstall --- +# Sudo-free: binary onto PATH, then started from there, which is what turns +# autostart on for the first run of an installed copy. + +install: build + @$(BUILD)/gitwatchd stop >/dev/null 2>&1 || true + @DEST="$(DEST)"; \ + if [ -n "$$DEST" ]; then mkdir -p "$$DEST"; else \ + 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"; break; fi; \ + done; \ + fi; \ + if [ -z "$$DEST" ]; then \ + echo " ⚠ no writable bin dir found. Install by hand: install -m 0755 $(BUILD)/gitwatchd /usr/local/bin/gitwatchd"; \ + exit 1; \ + fi; \ + install -m 0755 $(BUILD)/gitwatchd "$$DEST/gitwatchd"; \ + echo "✓ binary → $$DEST/gitwatchd"; \ + case ":$$PATH:" in *":$$DEST:"*) ;; \ + *) echo " ⚠ $$DEST is not on your PATH. Add: export PATH=\"$$DEST:\$$PATH\"";; esac; \ + "$$DEST/gitwatchd" start + @echo " Next: gitwatchd . to watch the current repo" + +# Uninstall never trusts PATH: everything is found by absolute path. +uninstall: + @for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + [ -x "$$d/gitwatchd" ] && "$$d/gitwatchd" autostart off >/dev/null 2>&1 || true; \ + done + @UNIT="$$HOME/.config/systemd/user/gitwatchd.service"; \ + STATE="$${XDG_STATE_HOME:-$$HOME/.local/state}/gitwatchd"; GONE=""; \ + if command -v systemctl >/dev/null 2>&1; then \ + systemctl --user disable --now gitwatchd >/dev/null 2>&1 || true; \ + fi; \ + if [ -f "$$UNIT" ]; then \ + rm -f "$$UNIT"; GONE="systemd unit"; echo " removed $$UNIT"; \ + command -v systemctl >/dev/null 2>&1 && systemctl --user daemon-reload >/dev/null 2>&1 || true; \ + fi; \ + PID=$$(cat "$$STATE/gitwatchd.pid" 2>/dev/null); \ + if [ -n "$$PID" ] && [ "$$(cat /proc/$$PID/comm 2>/dev/null)" = gitwatchd ]; then \ + kill "$$PID" 2>/dev/null || true; \ + for i in 1 2 3 4 5 6 7 8 9 10; do [ -d /proc/$$PID ] || break; sleep 0.2; done; \ + [ -d /proc/$$PID ] && kill -9 "$$PID" 2>/dev/null; \ + echo " stopped daemon (pid $$PID)"; \ + fi; \ + BIN=""; \ + for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + [ -f "$$d/gitwatchd" ] && rm -f "$$d/gitwatchd" && BIN=1 && echo " removed $$d/gitwatchd"; \ + done; \ + [ -n "$$BIN" ] && GONE="$${GONE:+$$GONE, }binary"; \ + [ -d "$$STATE" ] && rm -rf "$$STATE" && GONE="$${GONE:+$$GONE, }daemon state"; \ + if [ -n "$$GONE" ]; then echo "✓ uninstalled: $$GONE"; \ + else echo "✓ nothing to uninstall: no gitwatchd binary, unit or state found"; fi + @echo " (config at ~/.gitwatchd kept: 'rm ~/.gitwatchd' to reset watched repos too)" + clean: - rm -rf build + rm -rf $(BUILD) diff --git a/linux/cli.go b/linux/cli.go new file mode 100644 index 0000000..5095599 --- /dev/null +++ b/linux/cli.go @@ -0,0 +1,1168 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" + "unicode" +) + +// Keep in step with the macOS CLI (macos/Sources/CLI.swift) and Info.plist: +// the two implementations ship as one product and report one version. +const version = "0.2.0" + +const usageText = `gitwatchd - daemon that watches git repos and auto-commits changes + +USAGE + gitwatchd [flags] watch a repo + gitwatchd [args] + +EXAMPLES + gitwatchd . watch the current repo, commit locally + gitwatchd -r origin . also push every commit to origin + gitwatchd -r origin -b main -R . two-way sync: fetch commits made on + other machines and rebase yours on top + before each push to origin/main + gitwatchd status see everything being watched + gitwatchd rm blog stop watching, by name or path + +COMMANDS + add [flags] watch a repo (bare ` + "`gitwatchd [flags] `" + ` works too) + 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 + 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. + config [path|edit] print the config file path, or open it in your + editor (the one ` + "`git commit`" + ` uses) + help show this help + version print the version + +FLAGS (for add) + -s Wait after the last change before committing, so a + batch of writes lands as one commit. Default: 2. + -r Push to after every commit. Default: no push. + -b Branch to push to. Without it, a plain ` + "`git push `" + ` + decides. Only meaningful together with -r. + -R Before each push, pull commits made elsewhere and rebase + yours on top (` + "`git pull --rebase `" + `). Use with -r + when more than one machine pushes to the same branch. + -m Commit message; %d becomes the timestamp. + Default: "gitwatchd auto-commit (%d)". + -d Format string for that timestamp (see ` + "`man date`" + `). + Default: "+%Y-%m-%d %H:%M:%S". + -x Skip changes whose path matches this regular + expression (e.g. '\.log$' or 'build/'). + -M Skip committing while the repo has a merge in progress. + -f Commit anything already pending as soon as watching + starts (daemon launch, or when the repo is added). + -g Location of the .git directory, if elsewhere (--git-dir). + --paused Keep the repo in the config but don't watch it. This is + what ` + "`gitwatchd pause`" + ` sets. + +The daemon runs in the background and watches every repo listed in ~/.gitwatchd +` + +type ConfigError struct { + Label string // repo name, or the offending line for parse errors + Reason string // short fixed vocabulary, e.g. "repo not found" + Detail string // full path or config line, for status + RepoPath string // set when there is a path to re-check for healing +} + +// A watched-repo specification, expressed in gitwatch's flag vocabulary. +// +// gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg] +// [-x pattern] [-M] [-g gitdir] [-e events] +// +// Each config line is exactly the argument string you'd pass to gitwatch (modulo path resolution). +type RepoSpec struct { + Path string + Settle float64 // -s debounce seconds + DateFormat string // -d + Remote string // -r + Branch string // -b + Rebase bool // -R pull --rebase before push + Message string // -m (%d -> date) + Exclude string // -x regex; last one wins, as upstream + NoMergeCommit bool // -M + CommitOnStart bool // -f commit pending changes when watching starts + GitDir string // -g --git-dir + Paused bool // --paused (gitwatchd extension, not gitwatch) + Raw string // the config line this spec came from, for change detection + targetIndex int // which arg was the target, so add can persist it resolved +} + +func (s RepoSpec) Name() string { + return filepath.Base(s.Path) +} + +func (s RepoSpec) IsFileTarget() bool { + info, err := os.Stat(s.Path) + return err != nil || !info.IsDir() +} + +// Where git commands run: the target itself, or its parent for a file +// target (upstream's TARGETDIR). +func (s RepoSpec) WorkDir() string { + if s.IsFileTarget() { + return filepath.Dir(s.Path) + } + return s.Path +} + +func (s RepoSpec) Excludes(fullPath string) bool { + if s.Exclude == "" { + return false + } + re, err := regexp.Compile(s.Exclude) + if err != nil { + return false + } + return re.MatchString(fullPath) +} + +func main() { + os.Exit(cliRun(os.Args[1:])) +} + +func cliRun(args []string) int { + if len(args) == 0 { + os.Stdout.WriteString(usageText) + return 0 + } + switch args[0] { + case "help", "-h", "--help": + os.Stdout.WriteString(usageText) + return 0 + case "version", "--version": + fmt.Println("gitwatchd " + version) + return 0 + case "rm", "remove": + return cliRemove(args[1:]) + case "pause": + return cliSetPaused(args[1:], true) + case "resume": + return cliSetPaused(args[1:], false) + case "status": + return cliStatus() + case "doctor": + return cliDoctor() + case "start": + return cliStart() + case "stop": + return cliStop() + case "config": + return cliConfig(args[1:]) + case "autostart": + return cliAutostart(args[1:]) + case "add": + return cliAdd(args[1:]) + case "daemon": + return runDaemon() + default: + return cliAdd(args) + } +} + +func cliAdd(args []string) int { + spec, errMsg := parseRepoSpec(args) + if spec == nil { + if errMsg == "" { + errMsg = "could not parse arguments" + } + warn(errMsg) + return 1 + } + if _, err := os.Stat(spec.Path); err != nil { + warn("path does not exist: " + spec.Path) + return 1 + } + if !isRepo(spec.WorkDir(), spec.GitDir) { + warn("not inside a git repo: " + spec.Path + "\n run: git init " + spec.WorkDir()) + return 1 + } + for _, existing := range configSpecs() { + if existing.Path == spec.Path { + warn(fmt.Sprintf("already watching %s (%s)", spec.Name(), spec.Path)) + return 1 + } + } + + // Persist what the user typed (gitwatch-style line), with the target + // resolved to an absolute path: the daemon reads this file from a + // different working directory, so `gitwatchd .` must not store ".". + quoted := make([]string, len(args)) + for i, a := range args { + if i == spec.targetIndex { + a = spec.Path + } + quoted[i] = quoteIfNeeded(a) + } + configAppend(strings.Join(quoted, " ")) + ensureDaemonRunning() + + pushNote := "local only" + if spec.Remote != "" { + branch := spec.Branch + if branch == "" { + branch = currentBranch(spec.WorkDir(), spec.GitDir) + } + pushNote = "→ " + spec.Remote + "/" + branch + } + fmt.Printf("✓ watching %s (%s) %s, settle %ds\n", spec.Name(), spec.Path, pushNote, int(spec.Settle)) + fmt.Println(" gitwatchd status to see everything watched") + return 0 +} + +func cliRemove(args []string) int { + if len(args) == 0 { + warn("usage: gitwatchd rm ") + return 1 + } + nameOrPath := args[0] + n := configRemove(nameOrPath) + if n == 0 { + warn("no watched repo matches " + nameOrPath) + return 1 + } + ensureDaemonRunning() + entries := "entries" + if n == 1 { + entries = "entry" + } + fmt.Printf("✓ stopped watching %s (%d %s removed)\n", nameOrPath, n, entries) + return 0 +} + +func cliSetPaused(args []string, paused bool) int { + verb := "resume" + if paused { + verb = "pause" + } + if len(args) == 0 { + warn("usage: gitwatchd " + verb + " ") + return 1 + } + nameOrPath := args[0] + n := configSetPaused(nameOrPath, paused) + if n == 0 { + known := false + for _, s := range configSpecs() { + if configMatches(s, nameOrPath) { + known = true + } + } + if known { + state := "watching" + if paused { + state = "paused" + } + warn(nameOrPath + " is already " + state) + } else { + warn("no watched repo matches " + nameOrPath) + } + return 1 + } + ensureDaemonRunning() + if paused { + fmt.Printf("⏸ paused %s (resume with: gitwatchd resume %s)\n", nameOrPath, nameOrPath) + } else { + fmt.Printf("✓ resumed %s; catching up on anything that changed meanwhile\n", nameOrPath) + } + return 0 +} + +func cliStatus() int { + running := isDaemonRunning() + state := "not running" + if running { + state = "running" + } + fmt.Println("daemon: " + state) + fmt.Println("config: " + configPath()) + fmt.Println("") + specs, errors := configLoad() + if len(specs) == 0 && len(errors) == 0 { + fmt.Println("No repos watched yet") + return 0 + } + daemonErrors := map[string]RepoStatus{} + if running { + daemonErrors = repoStatuses() + } + repos := "repos" + if len(specs) == 1 { + repos = "repo" + } + fmt.Printf("Watching %d %s\n", len(specs), repos) + fmt.Println("") + now := time.Now() + for _, s := range specs { + branch := currentBranch(s.WorkDir(), s.GitDir) + pending := pendingCount(s.WorkDir(), s.GitDir) + errStatus, hasErr := daemonErrors[s.Path] + label := "" + if hasErr { + label = errStatus.ErrorLabel + } + fmt.Println(rowTitle(s.Name(), branch, s.Paused, pending, label)) + fmt.Println(" " + lastCommitSummary(s.WorkDir(), s.GitDir)) + if hasErr { + fmt.Println(" " + errorHeadline(errStatus.ErrorLabel, errStatus.Attempts)) + if errStatus.Detail != "" { + fmt.Println(" " + truncated(errStatus.Detail, 60)) + } + var next *time.Time + if errStatus.NextRetry != nil { + t := time.Unix(*errStatus.NextRetry, 0) + next = &t + } + fmt.Println(" " + retryLine(time.Unix(errStatus.LastAttempt, 0), next, now)) + } + } + for _, e := range errors { + fmt.Println(configErrorRow(e.Label, e.Reason)) + fmt.Println(" " + e.Detail) + } + return 0 +} + +// Undocumented diagnostic: the environment the daemon will use for git. +func cliDoctor() int { + fmt.Println("What the daemon will use for git:") + gitPath, err := exec.LookPath("git") + if err != nil { + fmt.Println(" git binary not found in PATH") + return 1 + } + _, gitVersion := runCommand(gitPath, []string{"--version"}, "") + fmt.Println(" git binary " + gitPath) + fmt.Println(" git --version " + gitVersion) + path := os.Getenv("PATH") + if path == "" { + path = "(unset)" + } + fmt.Println(" PATH " + path) + if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { + code, out := runCommand("ssh-add", []string{"-l"}, "") + n := 0 + if code == 0 && out != "" { + n = len(strings.Split(out, "\n")) + } + keys := "keys" + if n == 1 { + keys = "key" + } + 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") + } + } else { + fmt.Println(" SSH_AUTH_SOCK (unset) ⚠ SSH pushes will fail from the daemon unless keys are unencrypted") + } + return 0 +} + +func cliConfig(args []string) int { + if len(args) == 0 || args[0] == "path" { + fmt.Println(configPath()) + return 0 + } + if args[0] == "edit" { + return cliEditConfig() + } + warn("usage: gitwatchd config [path|edit]") + return 1 +} + +// Open the config in the editor `git commit` would use: `git var GIT_EDITOR` +// resolves $GIT_EDITOR, core.editor, $VISUAL, $EDITOR, then vi. Execs the +// editor in place of the CLI so full-screen editors keep the terminal; the +// daemon live-reloads off the file change, so there is nothing to do after. +func cliEditConfig() int { + configEnsureExists() + cwd, _ := os.Getwd() + code, out := gitRun([]string{"var", "GIT_EDITOR"}, cwd, "") + editor := "vi" + if code == 0 && out != "" { + editor = out + } + // The editor value can be a command with flags (e.g. "code --wait"), + // so hand it to the shell; the path rides in as $1, safely quoted. + argv := []string{"sh", "-c", editor + " \"$1\"", "gitwatchd-edit", configPath()} + err := syscall.Exec("/bin/sh", argv, os.Environ()) + warn(fmt.Sprintf("couldn't launch editor '%s': %v", editor, err)) + return 1 +} + +func cliAutostart(args []string) int { + if len(args) == 0 { + return autostartStatus() + } + switch args[0] { + case "on": + return autostartOn() + case "off": + return autostartOff() + case "status": + return autostartStatus() + default: + warn("usage: gitwatchd autostart [on|off|status]") + return 1 + } +} + +func cliStart() int { + if isDaemonRunning() { + fmt.Println("daemon already running") + return 0 + } + if unitInstalled() && systemctlPresent() { + if code, out := systemctlUser("start", "gitwatchd"); code != 0 { + warn("systemctl --user start gitwatchd failed: " + out) + return 1 + } + } else if err := spawnDaemon(); err != nil { + warn("could not start the daemon: " + err.Error()) + return 1 + } + for i := 0; i < 20 && !isDaemonRunning(); i++ { + time.Sleep(100 * time.Millisecond) + } + if !isDaemonRunning() { + logs := logfilePath() + if unitInstalled() && systemctlPresent() { + logs = "journalctl --user -u gitwatchd" + } + warn("daemon did not come up; check " + logs) + return 1 + } + fmt.Println("✓ daemon started") + return 0 +} + +func cliStop() int { + if !isDaemonRunning() { + fmt.Println("daemon not running") + return 0 + } + if unitInstalled() && systemctlPresent() && unitActive() { + systemctlUser("stop", "gitwatchd") + } else if pid := daemonPid(); pid > 0 { + syscall.Kill(pid, syscall.SIGTERM) + } + + // Wait for the exit so `gitwatchd stop && gitwatchd start` doesn't race + // the old process. + if waitForDaemonExit(5 * time.Second) { + fmt.Println("✓ daemon stopped") + return 0 + } + + // A daemon wedged in a git call ignores SIGTERM; stop still has to win. + pid := daemonPid() + if pid <= 0 { + warn("daemon did not exit and its pidfile names no process to kill") + return 1 + } + syscall.Kill(pid, syscall.SIGKILL) + if !waitForDaemonExit(2 * time.Second) { + warn(fmt.Sprintf("daemon (pid %d) survived SIGKILL", pid)) + return 1 + } + fmt.Println("✓ daemon stopped (it ignored the stop signal, so it was killed)") + return 0 +} + +func waitForDaemonExit(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if !isDaemonRunning() { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(100 * time.Millisecond) + } +} + +func spawnDaemon() error { + exe, err := os.Executable() + if err != nil { + return err + } + os.MkdirAll(stateDir(), 0o755) + logFile, err := os.OpenFile(logfilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer logFile.Close() + cmd := exec.Command(exe, "daemon") + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +// Best-effort: start the daemon if it isn't running (the running daemon +// live-reloads the config, so this is only for the cold case). +func ensureDaemonRunning() { + if os.Getenv("GITWATCHD_NO_SPAWN") != "" || isDaemonRunning() { + return + } + if unitInstalled() && systemctlPresent() { + systemctlUser("start", "gitwatchd") + return + } + spawnDaemon() +} + +func warn(msg string) { + fmt.Fprintln(os.Stderr, "✗ "+msg) +} + +// Config = a list of gitwatch-style argument lines, one repo per line. +// Hand-editable and CLI-writable; the CLI appends exactly what the user typed. + +func configPath() string { + if p := os.Getenv("GITWATCHD_CONFIG"); p != "" { + return p + } + return filepath.Join(homeDir(), ".gitwatchd") +} + +func configEnsureExists() { + if _, err := os.Stat(configPath()); err == nil { + return + } + template := `# gitwatchd: one repo per line. +# [-s secs] [-r remote [-b branch]] [-R] [-m msg] [-x pattern] [-M] [--paused] +# 'gitwatchd help' explains each flag. Examples: +# ~/code/my-notes +# -s 5 -r origin -b main ~/code/blog +# (from a terminal, 'gitwatchd .' adds the current repo here for you) +` + os.WriteFile(configPath(), []byte(template), 0o644) +} + +func configRawLines() []string { + text, err := os.ReadFile(configPath()) + if err != nil { + return nil + } + var lines []string + for _, l := range strings.Split(string(text), "\n") { + l = strings.TrimSpace(l) + if l != "" && !strings.HasPrefix(l, "#") { + lines = append(lines, l) + } + } + return lines +} + +func configSpecs() []*RepoSpec { + var specs []*RepoSpec + for _, line := range configRawLines() { + if spec, _ := parseRepoSpec(tokenize(line)); spec != nil { + spec.Raw = line + specs = append(specs, spec) + } + } + return specs +} + +func configLineErrors() []ConfigError { + var errs []ConfigError + for _, line := range configRawLines() { + if spec, msg := parseRepoSpec(tokenize(line)); spec == nil { + if msg == "" { + msg = "unparseable line" + } + errs = append(errs, ConfigError{Label: line, Reason: msg, Detail: line}) + } + } + return errs +} + +func configLoad() ([]*RepoSpec, []ConfigError) { + errs := configLineErrors() + var watchable []*RepoSpec + for _, spec := range configSpecs() { + reason := "" + if _, err := os.Stat(spec.Path); err != nil { + reason = "repo not found" + } else if !isRepo(spec.WorkDir(), spec.GitDir) { + reason = "not a git repo" + } else { + watchable = append(watchable, spec) + continue + } + errs = append(errs, ConfigError{Label: spec.Name(), Reason: reason, + Detail: spec.Path, RepoPath: spec.Path}) + } + return watchable, errs +} + +func configAppend(line string) { + configEnsureExists() + raw, _ := os.ReadFile(configPath()) + text := string(raw) + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + text += line + "\n" + os.WriteFile(configPath(), []byte(text), 0o644) +} + +func configMatches(spec *RepoSpec, nameOrPath string) bool { + return spec.Path == expandTilde(nameOrPath) || spec.Name() == nameOrPath || spec.Path == nameOrPath +} + +func configRemove(nameOrPath string) int { + raw, err := os.ReadFile(configPath()) + if err != nil { + return 0 + } + removed := 0 + var kept []string + for _, rawLine := range strings.Split(string(raw), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + kept = append(kept, rawLine) + continue + } + spec, _ := parseRepoSpec(tokenize(line)) + if spec != nil && configMatches(spec, nameOrPath) { + removed++ + continue + } + kept = append(kept, rawLine) + } + os.WriteFile(configPath(), []byte(strings.Join(kept, "\n")), 0o644) + return removed +} + +// Flip the --paused token on config lines matching `nameOrPath` (by full +// path or repo name, like remove). Pause lives in the config, not daemon +// state, so it survives daemon and machine restarts. Returns the number +// of lines changed. +func configSetPaused(nameOrPath string, paused bool) int { + raw, err := os.ReadFile(configPath()) + if err != nil { + return 0 + } + changed := 0 + var lines []string + for _, rawLine := range strings.Split(string(raw), "\n") { + trimmed := strings.TrimSpace(rawLine) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + lines = append(lines, rawLine) + continue + } + spec, _ := parseRepoSpec(tokenize(trimmed)) + if spec == nil || !configMatches(spec, nameOrPath) { + lines = append(lines, rawLine) + continue + } + rewritten, ok := togglingPaused(trimmed, spec.Path, paused) + if !ok { + lines = append(lines, rawLine) + continue + } + changed++ + lines = append(lines, rewritten) + } + if changed > 0 { + os.WriteFile(configPath(), []byte(strings.Join(lines, "\n")), 0o644) + } + return changed +} + +func togglingPaused(line string, path string, paused bool) (string, bool) { + tokens := tokenize(line) + spec, _ := parseRepoSpec(tokens) + if spec == nil || spec.Path != path || spec.Paused == paused { + return "", false + } + var kept []string + for _, t := range tokens { + if t != "--paused" { + kept = append(kept, t) + } + } + if paused { + kept = append([]string{"--paused"}, kept...) + } + quoted := make([]string, len(kept)) + for i, t := range kept { + quoted[i] = quoteIfNeeded(t) + } + return strings.Join(quoted, " "), true +} + +func quoteIfNeeded(s string) string { + if strings.Contains(s, `"`) && !strings.Contains(s, "'") { + return "'" + s + "'" + } + if strings.Contains(s, " ") || strings.Contains(s, `"`) || strings.Contains(s, "'") { + return `"` + s + `"` + } + return s +} + +// Split a config line into arguments on whitespace, respecting simple single +// or double quotes so that `-m "two words"` stays a single argument. +func tokenize(line string) []string { + var args []string + var current strings.Builder + var openQuote rune // the quote char we're inside, 0 if none + + finishArg := func() { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + } + + for _, ch := range line { + switch { + case openQuote != 0: + if ch == openQuote { + openQuote = 0 + } else { + current.WriteRune(ch) + } + case ch == '"' || ch == '\'': + openQuote = ch + case unicode.IsSpace(ch): + finishArg() + default: + current.WriteRune(ch) + } + } + finishArg() + return args +} + +// Parse a gitwatch-style argument list into a RepoSpec. +// Returns nil + an error message if there's no valid target. +func parseRepoSpec(args []string) (*RepoSpec, string) { + spec := &RepoSpec{ + Settle: 2, + DateFormat: "+%Y-%m-%d %H:%M:%S", + Message: "gitwatchd auto-commit (%d)", + } + target := "" + haveTarget := false + i := 0 + next := func() (string, bool) { + i++ + if i < len(args) { + return args[i], true + } + return "", false + } + + for i < len(args) { + a := args[i] + switch a { + case "-s": + v, ok := next() + d, err := strconv.ParseFloat(v, 64) + if !ok || err != nil || d < 0 { + return nil, "-s needs a number of seconds, 0 or more" + } + spec.Settle = d + case "-d": + if v, ok := next(); ok { + spec.DateFormat = v + } + case "-r", "-p": // -p: upstream's alias of -r + if v, ok := next(); ok { + spec.Remote = v + } + case "-b": + if v, ok := next(); ok { + spec.Branch = v + } + case "-R": + spec.Rebase = true + case "-m": + if v, ok := next(); ok { + spec.Message = v + } + case "-x": + v, ok := next() + if !ok { + return nil, "-x needs a valid regular expression" + } + if _, err := regexp.Compile(v); err != nil { + return nil, "-x needs a valid regular expression" + } + spec.Exclude = v + case "-M": + spec.NoMergeCommit = true + case "-f": + spec.CommitOnStart = true + case "-g": + if v, ok := next(); ok { + spec.GitDir = v + } + case "-e": + next() // -e accepted for gitwatch compatibility; ignored + case "--paused": + spec.Paused = true + default: + if strings.HasPrefix(a, "-") { + return nil, "unknown flag " + a + } + target = a // last bare arg wins as the target + spec.targetIndex = i + haveTarget = true + } + i++ + } + + if !haveTarget { + return nil, "no target path given" + } + spec.Path = normalizePath(target) + return spec, "" +} + +func normalizePath(p string) string { + p = expandTilde(p) + if !filepath.IsAbs(p) { + cwd, err := os.Getwd() + if err == nil { + p = filepath.Join(cwd, p) + } + } + return filepath.Clean(p) +} + +func expandTilde(p string) string { + if p == "~" { + return homeDir() + } + if strings.HasPrefix(p, "~/") { + return filepath.Join(homeDir(), p[2:]) + } + return p +} + +func homeDir() string { + h, err := os.UserHomeDir() + if err != nil { + return "/" + } + return h +} + +// One status tail at most: paused wins over errors, errors over pending. +func rowTitle(name, branch string, paused bool, pending int, errorLabel string) string { + base := name + " · " + branch + if paused { + return base + " · ⏸ paused" + } + if errorLabel != "" { + return base + " · ⚠ " + errorLabel + } + if pending == 1 { + return base + " · 1 pending change" + } + if pending > 1 { + return fmt.Sprintf("%s · %d pending changes", base, pending) + } + return base +} + +// Fixed-width row; the full path lives on the detail line. +func configErrorRow(label, reason string) string { + return truncated("⚠ "+label+" · "+reason, 48) +} + +func errorHeadline(label string, attempts int) string { + if attempts > 1 { + return fmt.Sprintf("⚠ %s (%d attempts)", label, attempts) + } + return "⚠ " + label +} + +func retryLine(lastTried time.Time, nextRetry *time.Time, now time.Time) string { + tried := "tried " + ago(now.Sub(lastTried).Seconds()) + if nextRetry == nil { + return tried + " · retries on next change" + } + dt := nextRetry.Sub(now).Seconds() + if dt <= 1 { + return tried + " · retrying now" + } + return tried + " · retrying in " + span(dt) +} + +// Autostart = a systemd user unit, 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 { + return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") +} + +func systemctlPresent() bool { + _, err := exec.LookPath("systemctl") + return err == nil +} + +func unitInstalled() bool { + _, err := os.Stat(unitPath()) + return err == nil +} + +func systemctlUser(args ...string) (int, string) { + return runCommand("systemctl", append([]string{"--user"}, args...), "") +} + +func unitActive() bool { + _, out := systemctlUser("is-active", "gitwatchd") + return out == "active" +} + +func unitEnabled() bool { + if !unitInstalled() { + return false + } + _, out := systemctlUser("is-enabled", "gitwatchd") + return out == "enabled" +} + +// Returns "" on success, or why not. +func writeAutostartUnit() 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] +Description=gitwatchd: watch git repos and auto-commit changes + +[Service] +ExecStart=%s daemon +Restart=on-failure +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.WriteFile(unitPath(), []byte(unit), 0o644); err != nil { + return "cannot write " + unitPath() + ": " + err.Error() + } + systemctlUser("daemon-reload") + return "" +} + +// Lingering keeps the user manager alive without an open session (headless boots). +func enableLinger() string { + code, out := runCommand("loginctl", []string{"enable-linger", os.Getenv("USER")}, "") + if code != 0 { + return strings.TrimSpace(out) + } + return "" +} + +func autostartOn() int { + if !systemctlPresent() { + warn("systemd not found: autostart needs a systemd user session.\n" + + " run the daemon manually instead: gitwatchd start") + return 1 + } + // Recorded before attempting, so a failed enable is retried on a later start. + setLaunchAtLogin(true) + if msg := writeAutostartUnit(); 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() { + if pid := daemonPid(); pid > 0 { + syscall.Kill(pid, syscall.SIGTERM) + waitForDaemonExit(5 * time.Second) + } + } + if code, out := systemctlUser("enable", "--now", "gitwatchd"); code != 0 { + warn("systemctl --user enable --now gitwatchd failed: " + out) + return 1 + } + if note := enableLinger(); note != "" { + fmt.Println("✓ autostart: on (systemd user unit 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)") + return 0 +} + +func autostartOff() int { + setLaunchAtLogin(false) // a standing opt-out: no later run turns it back on + if !systemctlPresent() { + warn("systemd not found: nothing to turn off (autostart was never installed)") + return 1 + } + if !unitInstalled() { + fmt.Println("autostart: already off") + return 0 + } + systemctlUser("disable", "--now", "gitwatchd") + os.Remove(unitPath()) + systemctlUser("daemon-reload") + fmt.Println("✓ autostart: off") + return 0 +} + +func autostartStatus() int { + if !systemctlPresent() { + fmt.Println("autostart: unavailable (systemd not found); run the daemon with: gitwatchd start") + return 0 + } + if !unitInstalled() { + fmt.Println("autostart: off") + return 0 + } + _, enabled := systemctlUser("is-enabled", "gitwatchd") + state := "on" + if enabled != "enabled" { + state = "installed but " + enabled + } + if unitActive() { + fmt.Printf("autostart: %s (daemon running)\n", state) + } else { + fmt.Printf("autostart: %s (daemon not running)\n", state) + } + 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 +} + +type autostartAction int + +const ( + autostartLeaveAlone autostartAction = iota + autostartEnableFirstRun + autostartReinstate + autostartReportUnavailable +) + +func autostartActionFor(c autostartConditions) autostartAction { + if !c.installedBinary { + return autostartLeaveAlone + } + if c.recorded && !c.wantsOn { + return autostartLeaveAlone // `autostart off` is never overridden + } + if !c.systemdPresent { + if c.recorded { + return autostartLeaveAlone // said once already, not on every start + } + return autostartReportUnavailable + } + if !c.recorded { + return autostartEnableFirstRun + } + if c.unitEnabled { + return autostartLeaveAlone + } + return autostartReinstate +} + +// 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 != "" { + return msg + } + if code, out := systemctlUser("enable", "gitwatchd"); code != 0 { + return "systemctl --user enable gitwatchd failed: " + out + } + enableLinger() // best effort, as for `autostart on` + 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. +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(), + } + if conditions.systemdPresent { + conditions.unitEnabled = unitEnabled() + } + action := autostartActionFor(conditions) + if action == autostartLeaveAlone { + return "" + } + setLaunchAtLogin(true) // recorded before the outcome, so a failure is retried + if action == autostartReportUnavailable { + return "autostart: unavailable (systemd not found); to have the daemon come back " + + "after a reboot, run `gitwatchd start` from your session startup" + } + if msg := enableAutostartForNextBoot(); msg != "" { + return "autostart could not be enabled: " + msg + } + if action == autostartEnableFirstRun { + return "autostart: on (systemd user unit enabled on first run; " + + "turn it off with `gitwatchd autostart off`)" + } + return "autostart: the systemd user unit had gone missing, re-enabled it" +} diff --git a/linux/cli_test.go b/linux/cli_test.go new file mode 100644 index 0000000..cc5dc22 --- /dev/null +++ b/linux/cli_test.go @@ -0,0 +1,672 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// CLI contract tests run the real cliRun against a scratch config directory +// (never the user's ~/.gitwatchd) with daemon-spawning disabled. + +func withTemporaryConfig(t *testing.T) { + t.Helper() + t.Setenv("GITWATCHD_NO_SPAWN", "1") + dir := t.TempDir() + t.Setenv("GITWATCHD_CONFIG", filepath.Join(dir, "gitwatchd")) + t.Setenv("GITWATCHD_STATE_DIR", filepath.Join(dir, "state")) +} + +func TestAddRefusesAMissingPath(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"add", filepath.Join(t.TempDir(), "no-such-dir")}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be written to the config") + } +} + +func TestAddRefusesANonRepo(t *testing.T) { + withTemporaryConfig(t) + dir := filepath.Join(t.TempDir(), "plain-folder") + os.MkdirAll(dir, 0o755) + if cliRun([]string{"add", dir}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be written to the config") + } +} + +func TestAddPersistsExactlyWhatWasTyped(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + if cliRun([]string{"add", "-s", "5", "-m", "two words", repo.path}) != 0 { + t.Fatal("add failed") + } + lines := configRawLines() + want := `-s 5 -m "two words" ` + repo.path + if len(lines) != 1 || lines[0] != want { + t.Errorf("got %v, want [%q]", lines, want) + } + spec := configSpecs()[0] + if spec.Settle != 5 || spec.Message != "two words" { + t.Errorf("got %+v", spec) + } +} + +func TestAddResolvesARelativeTargetForTheDaemon(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + t.Chdir(repo.path) + if cliRun([]string{"-s", "5", "."}) != 0 { + t.Fatal("add failed") + } + // The daemon reads the config from a different working directory, so + // the stored target must be absolute; the flags stay verbatim. + if got := configRawLines()[0]; got != "-s 5 "+repo.path { + t.Errorf("got %q", got) + } +} + +func TestDuplicateAddFailsAndLeavesOneEntry(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{repo.path}) != 1 { + t.Error("duplicate add should fail") + } + if cliRun([]string{"add", "-s", "5", repo.path}) != 1 { + t.Error("different flags, same repo: still a duplicate") + } + if len(configSpecs()) != 1 { + t.Errorf("got %d entries", len(configSpecs())) + } +} + +func TestBarePathIsAnImplicitAdd(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + if cliRun([]string{repo.path}) != 0 { + t.Fatal("bare add failed") + } + specs := configSpecs() + if len(specs) != 1 || specs[0].Path != repo.path { + t.Errorf("got %+v", specs) + } +} + +func TestRmMatchesByName(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", repo.spec().Name()}) != 0 { + t.Error("rm by name failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmMatchesByFullPath(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", repo.path}) != 0 { + t.Error("rm by path failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmUnknownFailsAndLeavesConfigAlone(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", "not-a-watched-repo"}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 1 { + t.Error("the config must be untouched") + } +} + +func TestPauseAndResumeFlipPausedInConfig(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{"-s", "5", repo.path}) + if cliRun([]string{"pause", repo.spec().Name()}) != 0 { + t.Fatal("pause failed") + } + if !configSpecs()[0].Paused { + t.Error("spec should be paused") + } + if got := configRawLines()[0]; got != "--paused -s 5 "+repo.path { + t.Errorf("the rest of the line must survive untouched, got %q", got) + } + if cliRun([]string{"resume", repo.spec().Name()}) != 0 { + t.Fatal("resume failed") + } + if configSpecs()[0].Paused { + t.Error("spec should be resumed") + } + if got := configRawLines()[0]; got != "-s 5 "+repo.path { + t.Errorf("got %q", got) + } +} + +func TestPauseByFullPath(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"pause", repo.path}) != 0 { + t.Fatal("pause failed") + } + if !configSpecs()[0].Paused { + t.Error("spec should be paused") + } +} + +func TestPausingTwiceFailsPolitely(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + cliRun([]string{"pause", repo.path}) + before := configRawLines() + if cliRun([]string{"pause", repo.path}) != 1 { + t.Error("expected failure") + } + after := configRawLines() + if strings.Join(before, "\n") != strings.Join(after, "\n") { + t.Error("the config must be unchanged") + } +} + +func TestPauseUnknownFails(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"pause", "nothing-here"}) != 1 { + t.Error("expected failure") + } +} + +func TestVersionDoesNotFallThroughToAdd(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"version"}) != 0 || cliRun([]string{"--version"}) != 0 { + t.Error("version should succeed") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be added") + } +} + +func TestBrokenConfigEntriesSurfaceInLoadAndStatus(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + configAppend(filepath.Join(t.TempDir(), "vanished-repo")) + configAppend("-z bogus /tmp/x") + specs, errs := configLoad() + if len(specs) != 1 { + t.Errorf("the healthy repo is unaffected, got %d", len(specs)) + } + if len(errs) != 2 { + t.Fatalf("got %d errors", len(errs)) + } + foundMissing, foundUnknown := false, false + for _, e := range errs { + if e.Reason == "repo not found" { + foundMissing = true + } + if strings.Contains(e.Reason, "unknown flag") { + foundUnknown = true + } + } + if !foundMissing || !foundUnknown { + t.Errorf("got %+v", errs) + } + if cliRun([]string{"status"}) != 0 { + t.Error("status renders them rather than crashing or hiding them") + } +} + +func TestPausedParsesAlongsideGitwatchFlags(t *testing.T) { + spec, errMsg := parseRepoSpec([]string{"--paused", "-s", "2", "/tmp/x"}) + if spec == nil { + t.Fatal(errMsg) + } + if !spec.Paused || spec.Settle != 2 { + t.Errorf("got %+v", spec) + } +} + +func TestPauseRewritesAndResumeRestores(t *testing.T) { + line := "-s 2 -r origin -b main -R /tmp/x" + pausedLine, ok := togglingPaused(line, "/tmp/x", true) + if !ok || pausedLine != "--paused -s 2 -r origin -b main -R /tmp/x" { + t.Fatalf("got %q", pausedLine) + } + resumed, ok := togglingPaused(pausedLine, "/tmp/x", false) + if !ok || resumed != line { + t.Errorf("got %q", resumed) + } +} + +func TestQuotedMessagesSurviveTheRewrite(t *testing.T) { + got, ok := togglingPaused(`-m "two words" /tmp/x`, "/tmp/x", true) + if !ok || got != `--paused -m "two words" /tmp/x` { + t.Errorf("got %q", got) + } +} + +func TestEmbeddedQuotesSurviveTheRewrite(t *testing.T) { + got, ok := togglingPaused(`-m 'say "hi"' /tmp/x`, "/tmp/x", true) + if !ok || got != `--paused -m 'say "hi"' /tmp/x` { + t.Errorf("got %q", got) + } + got, ok = togglingPaused(`-m "don't" /tmp/x`, "/tmp/x", true) + if !ok || got != `--paused -m "don't" /tmp/x` { + t.Errorf("got %q", got) + } +} + +func TestOtherLinesAreLeftAlone(t *testing.T) { + if _, ok := togglingPaused("-s 2 /tmp/other", "/tmp/x", true); ok { + t.Error("a line for another repo must not be rewritten") + } +} + +func TestNoOpToggleChangesNothing(t *testing.T) { + if _, ok := togglingPaused("--paused /tmp/x", "/tmp/x", true); ok { + t.Error("already paused: nothing to do") + } + if _, ok := togglingPaused("/tmp/x", "/tmp/x", false); ok { + t.Error("already watching: nothing to do") + } +} + +func TestTokenizeRespectsQuotes(t *testing.T) { + got := tokenize(`-m "two words" -x '\.log$' /tmp/x`) + want := []string{"-m", "two words", "-x", `\.log$`, "/tmp/x"} + if len(got) != len(want) { + t.Fatalf("got %v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("token %d: got %q, want %q", i, got[i], want[i]) + } + } +} + +// Autostart onboarding: what a daemon start does about autostart, given the +// recorded wish, where the binary lives 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}, + {"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, + recorded: true, wantsOn: true}, autostartReinstate}, + {"an opt-out is never overridden", + autostartConditions{installedBinary: true, systemdPresent: true, recorded: true}, + autostartLeaveAlone}, + {"the first installed run without systemd says so", + autostartConditions{installedBinary: true}, autostartReportUnavailable}, + {"without systemd it says so once, not on every start", + autostartConditions{installedBinary: true, recorded: true, wantsOn: true}, + autostartLeaveAlone}, + {"an opt-out without systemd stays quiet", + autostartConditions{installedBinary: true, recorded: true}, autostartLeaveAlone}, + } + for _, c := range cases { + if got := autostartActionFor(c.conditions); got != c.want { + t.Errorf("%s: action %d, want %d (%+v)", c.what, got, c.want, c.conditions) + } + } +} + +func TestAutostartWishIsRecordedAsLaunchAtLogin(t *testing.T) { + withTemporaryConfig(t) + if _, recorded := launchAtLogin(); recorded { + t.Error("nothing is on record until the user or a first run says so") + } + setLaunchAtLogin(true) + if on, recorded := launchAtLogin(); !on || !recorded { + t.Errorf("after recording on: on=%v recorded=%v", on, recorded) + } + setLaunchAtLogin(false) + if on, recorded := launchAtLogin(); on || !recorded { + t.Errorf("after recording off: on=%v recorded=%v", on, recorded) + } + raw, err := os.ReadFile(statePath()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"launch-at-login": "off"`) { + t.Errorf("the setting must be readable under its own key:\n%s", raw) + } +} + +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) + } + for _, dir := range []string{".local/bin", "bin"} { + if !isInstalledBinary(filepath.Join(home, dir, "gitwatchd")) { + t.Errorf("%s is one of the install destinations", dir) + } + } + if isInstalledBinary(filepath.Join(home, "build", "gitwatchd")) { + t.Error("a build directory holds a development copy") + } +} + +// The help text states flag defaults and exclusion behaviour as facts; +// these tests pin them so the help can't silently drift from the code. + +func TestDefaultsForBarePath(t *testing.T) { + spec, errMsg := parseRepoSpec([]string{"/tmp/x"}) + if spec == nil { + t.Fatal(errMsg) + } + if spec.Settle != 2 { // -s "Default: 2" + t.Errorf("settle = %v", spec.Settle) + } + if spec.Remote != "" { // -r "Default: no push" + t.Errorf("remote = %q", spec.Remote) + } + if spec.Message != "gitwatchd auto-commit (%d)" { + t.Errorf("message = %q", spec.Message) + } + if spec.DateFormat != "+%Y-%m-%d %H:%M:%S" { // upstream's exact default, leading + included + t.Errorf("dateFormat = %q", spec.DateFormat) + } + if spec.Branch != "" || spec.Rebase || spec.Exclude != "" || spec.NoMergeCommit || spec.Paused { + t.Errorf("unexpected non-default: %+v", spec) + } + if spec.CommitOnStart { + t.Error("-f is opt-in: a deliberate manual state is not flushed") + } +} + +func TestPIsAnAliasOfR(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-p", "origin", "/tmp/x"}) + if spec == nil || spec.Remote != "origin" { + t.Errorf("got %+v", spec) + } +} + +func TestSettleRejectsBadValues(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); spec != nil { + t.Error("-s -1 should be rejected") + } + if spec, _ := parseRepoSpec([]string{"-s", "soon", "/tmp/x"}); spec != nil { + t.Error("-s soon should be rejected") + } + if _, msg := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); msg != "-s needs a number of seconds, 0 or more" { + t.Errorf("wrong message: %q", msg) + } + spec, _ := parseRepoSpec([]string{"-s", "0", "/tmp/x"}) + if spec == nil || spec.Settle != 0 { + t.Error("-s 0 is valid") + } +} + +func TestExcludeMatchesAnywhereInPath(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/deep/dir/debug.log") { + t.Error("should match a nested .log file") + } + if spec.Excludes("/tmp/x/log.txt") { + t.Error("should not match log.txt") + } +} + +func TestExcludeDirectoryPattern(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", "build/", "/tmp/x"}) + if !spec.Excludes("/tmp/x/build/out.o") { + t.Error("should exclude the build subtree") + } + if spec.Excludes("/tmp/x/src/out.o") { + t.Error("should not exclude src") + } +} + +func TestLastExcludeWins(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "-x", `\.tmp$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/b.tmp") { + t.Error("the last -x should apply") + } + if spec.Excludes("/tmp/x/a.log") { + t.Error("the first -x should be forgotten, as upstream") + } +} + +func TestInvalidExcludeIsAParseError(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-x", "*.log", "/tmp/x"}); spec != nil { + t.Error("an invalid regex must be a parse error, not a silent no-op") + } +} + +func TestNoExcludeByDefault(t *testing.T) { + spec, _ := parseRepoSpec([]string{"/tmp/x"}) + if spec.Excludes("/tmp/x/anything.log") { + t.Error("nothing is excluded without -x") + } +} + +// The help is the contract; these hold it to the implementation. The +// canonical lists below ARE the documented surface: adding a flag or command +// to only one side of the contract fails one of these tests. + +var contractValueFlags = map[string]string{ + "-s": "2", "-r": "origin", "-b": "main", "-m": "msg", + "-d": "+%Y", "-x": `\.log$`, "-g": "/tmp/gd", +} + +var contractBoolFlags = []string{"-R", "-M", "-f", "--paused"} +var contractCommands = []string{"add", "rm", "pause", "resume", "status", + "start", "stop", "autostart", "config", "help", "version"} + +func TestDocumentedFlagsParse(t *testing.T) { + for flag, value := range contractValueFlags { + if spec, msg := parseRepoSpec([]string{flag, value, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } + for _, flag := range contractBoolFlags { + if spec, msg := parseRepoSpec([]string{flag, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } +} + +func TestContractAppearsInHelp(t *testing.T) { + for flag := range contractValueFlags { + if !strings.Contains(usageText, flag+" <") { + t.Errorf("help is missing %s with its ", flag) + } + } + for _, flag := range contractBoolFlags { + if !strings.Contains(usageText, flag) { + t.Errorf("help is missing %s", flag) + } + } + for _, command := range contractCommands { + if !strings.Contains(usageText, command) { + t.Errorf("help is missing the %s command", command) + } + } +} + +func TestNoPhantomFlagsInHelp(t *testing.T) { + known := map[string]bool{} + for flag := range contractValueFlags { + known[flag] = true + } + for _, flag := range contractBoolFlags { + known[flag] = true + } + for _, line := range strings.Split(usageText, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "-") { + continue + } + flag := strings.Fields(line)[0] + if !known[flag] { + t.Errorf("help documents %s, which the contract list doesn't know", flag) + } + } +} + +// What `status` shows, asserted as exact strings: the same rows the macOS +// menu renders, so status reads identically across machines. + +func TestHealthyRowIsNameAndBranch(t *testing.T) { + if got := rowTitle("notes", "main", false, 0, ""); got != "notes · main" { + t.Errorf("got %q", got) + } +} + +func TestPendingEditsShowACount(t *testing.T) { + if got := rowTitle("notes", "main", false, 3, ""); got != "notes · main · 3 pending changes" { + t.Errorf("got %q", got) + } + if got := rowTitle("notes", "main", false, 1, ""); got != "notes · main · 1 pending change" { + t.Errorf("got %q", got) + } +} + +func TestErrorLabelFlagsTheRow(t *testing.T) { + if got := rowTitle("notes", "main", false, 0, "push failing"); got != "notes · main · ⚠ push failing" { + t.Errorf("got %q", got) + } +} + +func TestOutcomeLabels(t *testing.T) { + cases := map[CommitOutcome]string{ + PushFailed: "push failing", + RebaseConflict: "rebase conflict", + CommitFailed: "commit failing", + Pushed: "", + } + for kind, want := range cases { + if got := (Outcome{Kind: kind, Detail: "x"}).ErrorLabel(); got != want { + t.Errorf("kind %v: got %q, want %q", kind, got, want) + } + } +} + +func TestConfigErrorRow(t *testing.T) { + if got := configErrorRow("demo-repo", "repo not found"); got != "⚠ demo-repo · repo not found" { + t.Errorf("got %q", got) + } + longLabel := "" + for i := 0; i < 100; i++ { + longLabel += "y" + } + if got := configErrorRow(longLabel, "not a git repo"); len([]rune(got)) != 48 { + t.Errorf("rows stay fixed width; got %d runes", len([]rune(got))) + } +} + +func TestPausedBeatsEveryOtherTail(t *testing.T) { + if got := rowTitle("notes", "main", true, 3, "push failing"); got != "notes · main · ⏸ paused" { + t.Errorf("got %q", got) + } +} + +func TestErrorHeadlineCountsAttempts(t *testing.T) { + if got := errorHeadline("push failing", 1); got != "⚠ push failing" { + t.Errorf("got %q", got) + } + if got := errorHeadline("push failing", 4); got != "⚠ push failing (4 attempts)" { + t.Errorf("got %q", got) + } +} + +func TestRetryLine(t *testing.T) { + now := time.Unix(1_000_000, 0) + next := now.Add(180 * time.Second) + if got := retryLine(now.Add(-120*time.Second), &next, now); got != "tried 2m ago · retrying in 3m" { + t.Errorf("got %q", got) + } +} + +func TestRetryLineWithoutTimer(t *testing.T) { + now := time.Unix(1_000_000, 0) + if got := retryLine(now.Add(-30*time.Second), nil, now); got != "tried 30s ago · retries on next change" { + t.Errorf("got %q", got) + } +} + +func TestTruncation(t *testing.T) { + long := "" + for i := 0; i < 100; i++ { + long += "x" + } + if got := truncated(long, 20); len([]rune(got)) != 20 { + t.Errorf("got %d runes", len([]rune(got))) + } + if got := truncated("short", 20); got != "short" { + t.Errorf("got %q", got) + } +} + +func TestBackoffSchedule(t *testing.T) { + want := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second, + 240 * time.Second, 480 * time.Second, 960 * time.Second, + 30 * time.Minute, 30 * time.Minute} + for i, w := range want { + if got := backoffDelay(i + 1); got != w { + t.Errorf("after %d failures: got %v, want %v", i+1, got, w) + } + } + // A day-long outage: the doubling must saturate at the cap, never overflow. + for _, n := range []int{30, 288, 100000} { + if got := backoffDelay(n); got != backoffCap { + t.Errorf("after %d failures: got %v, want the %v cap", n, got, backoffCap) + } + } +} + +func TestErrorSummaryPicksRejectionLine(t *testing.T) { + out := `To /tmp/origin.git + ! [rejected] main -> main (fetch first) +error: failed to push some refs to '/tmp/origin.git' +hint: Updates were rejected because the remote contains work that you do not have` + if got := errorSummary(out); got != "! [rejected] main -> main (fetch first)" { + t.Errorf("got %q", got) + } +} + +func TestErrorSummaryPicksFatalLine(t *testing.T) { + out := "fatal: unable to access 'https://x/': Could not resolve host\nsome trailer" + if got := errorSummary(out); got != "fatal: unable to access 'https://x/': Could not resolve host" { + t.Errorf("got %q", got) + } +} + +func TestErrorSummaryFallsBackToLastLine(t *testing.T) { + if got := errorSummary("lint says no"); got != "lint says no" { + t.Errorf("got %q", got) + } +} diff --git a/linux/daemon.go b/linux/daemon.go new file mode 100644 index 0000000..5ff8891 --- /dev/null +++ b/linux/daemon.go @@ -0,0 +1,613 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "math" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + "unsafe" +) + +// GITWATCHD_STATE_DIR overrides, which is how tests get a daemon of their own. +func stateDir() string { + if d := os.Getenv("GITWATCHD_STATE_DIR"); d != "" { + return d + } + base := os.Getenv("XDG_STATE_HOME") + if base == "" { + base = filepath.Join(homeDir(), ".local", "state") + } + return filepath.Join(base, "gitwatchd") +} + +func statePath() string { return filepath.Join(stateDir(), "state.json") } +func stateLockPath() string { return filepath.Join(stateDir(), "state.lock") } +func pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } +func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } + +// The event set gitwatch passes to inotifywait: +// close_write,move,move_self,delete,create,modify. +const watchMask = syscall.IN_CLOSE_WRITE | syscall.IN_MOVED_FROM | syscall.IN_MOVED_TO | + syscall.IN_MOVE_SELF | syscall.IN_DELETE | syscall.IN_DELETE_SELF | + syscall.IN_CREATE | syscall.IN_MODIFY + +// A repo's current error, as published by the daemon. Watchers write it, and +// `gitwatchd status` is a view over it. +type RepoStatus struct { + ErrorLabel string `json:"errorLabel"` + Detail string `json:"detail,omitempty"` + Attempts int `json:"attempts"` + LastAttempt int64 `json:"lastAttempt"` + NextRetry *int64 `json:"nextRetry,omitempty"` +} + +// Everything gitwatchd persists; a healthy repo has no entry under "repos". +// The shape is the cross-platform contract (names match macos/Sources/StateDB.swift), +// pretty-printed in stable key order because people read and diff it. +type persistedState struct { + LaunchAtLogin string `json:"launch-at-login,omitempty"` // "on", "off", absent: never asked + Repos map[string]RepoStatus `json:"repos,omitempty"` +} + +// Lock-free: writes land by rename. Absent or unreadable state reads as empty. +func readState() persistedState { + raw, err := os.ReadFile(statePath()) + if err != nil { + return persistedState{} + } + var s persistedState + if json.Unmarshal(raw, &s) != nil { + return persistedState{} + } + return s +} + +// Daemon and CLI both rewrite the whole file; the flock stops one from writing +// off a stale read. The lock is its own file, never state.json: renaming over a +// locked file leaves the holder pinning an unlinked inode while the next writer +// locks its replacement. +func updateState(change func(*persistedState)) { + os.MkdirAll(stateDir(), 0o755) + lock, err := os.OpenFile(stateLockPath(), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return + } + defer lock.Close() + if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX) != nil { + return + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + + s := readState() + change(&s) + writeState(s) +} + +func writeState(s persistedState) { + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + return + } + raw = append(raw, '\n') + tmp := statePath() + ".tmp" + if os.WriteFile(tmp, raw, 0o644) == nil { + os.Rename(tmp, statePath()) + } +} + +func repoStatuses() map[string]RepoStatus { + statuses := readState().Repos + if statuses == nil { + return map[string]RepoStatus{} + } + return statuses +} + +func setRepoStatus(repoPath string, status *RepoStatus) { + updateState(func(s *persistedState) { + if status == nil { + delete(s.Repos, repoPath) + return + } + if s.Repos == nil { + s.Repos = map[string]RepoStatus{} + } + s.Repos[repoPath] = *status + }) +} + +func keepOnlyRepos(watched map[string]bool) { + updateState(func(s *persistedState) { + for path := range s.Repos { + if !watched[path] { + delete(s.Repos, path) + } + } + }) +} + +func launchAtLogin() (on bool, recorded bool) { + value := readState().LaunchAtLogin + return value != "off", value != "" +} + +func setLaunchAtLogin(on bool) { + value := "off" + if on { + value = "on" + } + updateState(func(s *persistedState) { s.LaunchAtLogin = value }) +} + +// One watcher per watchable repo, kept in step with the config file as it is edited. +type daemon struct { + watchers map[string]*repoWatcher // by repo path + previousSpecs map[string]*RepoSpec // the last config seen, paused entries included + logf func(format string, args ...any) +} + +func runDaemon() int { + lock, err := acquireDaemonLock() + if err != nil { + fmt.Fprintln(os.Stderr, "✗ daemon already running") + return 1 + } + defer lock.Close() + + d := &daemon{ + watchers: map[string]*repoWatcher{}, + previousSpecs: map[string]*RepoSpec{}, + logf: func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + }, + } + d.logf("gitwatchd %s: watching config %s", version, configPath()) + if msg := reconcileAutostart(); msg != "" { + d.logf("%s", msg) + } + d.reloadConfig() + + configChanged := make(chan struct{}, 1) + go watchConfigFile(configChanged) + go func() { + for range configChanged { + time.Sleep(300 * time.Millisecond) // let editors finish the save + // Whatever else the save queued is covered by this one reload. + select { + case <-configChanged: + default: + } + d.reloadConfig() + } + }() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + <-signals + for _, w := range d.watchers { + w.stopWatching() + } + d.logf("gitwatchd stopped") + return 0 +} + +func (d *daemon) reloadConfig() { + specs, errs := configLoad() + for _, e := range errs { + d.logf("config: %s: %s", e.Label, e.Reason) + } + + desired := map[string]*RepoSpec{} + watched := map[string]bool{} + for _, s := range specs { + desired[s.Path] = s + watched[s.Path] = true + } + keepOnlyRepos(watched) + + for path, w := range d.watchers { + s, wanted := desired[path] + if wanted && !s.Paused && s.Raw == w.spec.Raw { + continue + } + w.stopWatching() + delete(d.watchers, path) + d.logf("stopped watching %s", w.spec.Name()) + } + + for path, s := range desired { + if s.Paused { + continue + } + if _, running := d.watchers[path]; running { + continue + } + w, err := newRepoWatcher(s, setRepoStatus, d.logf) + if err != nil { + d.logf("could not watch %s: %v", s.Name(), err) + continue + } + d.watchers[path] = w + w.publishStatus() // surface watch-registration problems immediately + d.logf("watching %s (%s)", s.Name(), s.Path) + + prev, existed := d.previousSpecs[path] + resumed := existed && prev.Paused + if s.CommitOnStart || resumed { + w.flushNow() // -f, or a resume catching up on what piled up + } + } + + d.previousSpecs = desired +} + +// Watch the directory, not the file: editors replace the file on save. +func watchConfigFile(changed chan struct{}) { + fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) + if err != nil { + return + } + if _, err := syscall.InotifyAddWatch(fd, filepath.Dir(configPath()), watchMask); err != nil { + syscall.Close(fd) + return + } + configFile := filepath.Base(configPath()) + readInotifyEvents(fd, func(_ int, _ uint32, name string) { + if name == configFile { + queueWakeup(changed) + } + }) +} + +// Each event is a fixed header plus Len bytes of NUL-padded name; the buffer +// must be big enough to hold a whole one. +func readInotifyEvents(fd int, onEvent func(watchDescriptor int, mask uint32, name string)) { + buf := make([]byte, 64*1024) + for { + n, err := syscall.Read(fd, buf) + if err != nil || n <= 0 { + return // the fd was closed + } + for offset := 0; offset+syscall.SizeofInotifyEvent <= n; { + event := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset])) + name := "" + if event.Len > 0 { + start := offset + syscall.SizeofInotifyEvent + name = string(bytes.TrimRight(buf[start:start+int(event.Len)], "\x00")) + } + offset += syscall.SizeofInotifyEvent + int(event.Len) + onEvent(int(event.Wd), event.Mask, name) + } + } +} + +// A wakeup already queued covers this one: the receiver reads current state. +func queueWakeup(ch chan struct{}) { + select { + case ch <- struct{}{}: + default: + } +} + +// Single-instance guard: the daemon holds an exclusive flock on the pidfile +func acquireDaemonLock() (*os.File, error) { + os.MkdirAll(stateDir(), 0o755) + f, err := os.OpenFile(pidfilePath(), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, err + } + f.Truncate(0) + f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0) + return f, nil +} + +func isDaemonRunning() bool { + f, err := os.Open(pidfilePath()) + if err != nil { + return false + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_SH|syscall.LOCK_NB); err != nil { + return true // the daemon holds the exclusive lock + } + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + return false +} + +func daemonPid() int { + raw, err := os.ReadFile(pidfilePath()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) + return pid +} + +type repoError struct { + outcome Outcome + lastAttempt time.Time + attempts int // consecutive failures + nextRetry *time.Time // nil: no auto retry, waits for a change +} + +// Recursive inotify watcher for one repo: gitwatch's -s debounce and -x excludes, +// plus .git-churn filtering so our own commits don't retrigger. Its reliability +// layer (error state, push retries with backoff) never changes what a commit +// cycle does; it only re-runs the push stage of one that failed. +type repoWatcher struct { + spec *RepoSpec + + inotifyFD int + dirByWatchDescriptor map[int]string + watchMu sync.Mutex // guards dirByWatchDescriptor and watchFailure + + fileChanges chan struct{} + flushRequests chan struct{} + stopping chan struct{} + stopped chan struct{} + + lastError *repoError + watchFailure string // e.g. the inotify watch limit; surfaced, never fatal + + // Called after every cycle; nil while healthy. The daemon publishes it for `status`. + onStatus func(path string, status *RepoStatus) + logf func(format string, args ...any) +} + +func newRepoWatcher(spec *RepoSpec, onStatus func(string, *RepoStatus), + logf func(string, ...any)) (*repoWatcher, error) { + fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) + if err != nil { + return nil, err + } + w := &repoWatcher{ + spec: spec, + inotifyFD: fd, + dirByWatchDescriptor: map[int]string{}, + fileChanges: make(chan struct{}, 64), + flushRequests: make(chan struct{}, 1), + stopping: make(chan struct{}), + stopped: make(chan struct{}), + onStatus: onStatus, + logf: logf, + } + if spec.IsFileTarget() { + // Watch the parent directory and filter to the file's name, so + // atomic saves (write temp + rename) keep triggering. + w.watchDirectory(filepath.Dir(spec.Path)) + } else { + w.watchTree(spec.Path) + } + go readInotifyEvents(fd, w.handleInotifyEvent) + go w.runCycles() + return w, nil +} + +// Register a watch, surfacing inotify limit exhaustion as repo state +// instead of crashing (fire-and-forget, like every other runtime failure). +func (w *repoWatcher) watchDirectory(dir string) { + wd, err := syscall.InotifyAddWatch(w.inotifyFD, dir, watchMask) + if err != nil { + w.recordWatchFailure(dir, err) + return + } + w.watchMu.Lock() + w.dirByWatchDescriptor[wd] = dir + w.watchMu.Unlock() +} + +func (w *repoWatcher) recordWatchFailure(dir string, err error) { + w.watchMu.Lock() + defer w.watchMu.Unlock() + if err == syscall.ENOSPC { + w.watchFailure = "inotify watch limit reached: raise fs.inotify.max_user_watches " + + "(sudo sysctl fs.inotify.max_user_watches=524288)" + } else if !os.IsNotExist(err) { + w.watchFailure = "watch failed for " + dir + ": " + err.Error() + } +} + +// inotify watches one directory each, so a repo means a watch per directory. +func (w *repoWatcher) watchTree(root string) { + filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil + } + if d.Name() == ".git" { + return filepath.SkipDir + } + w.watchDirectory(path) + return nil + }) +} + +func (w *repoWatcher) handleInotifyEvent(watchDescriptor int, mask uint32, name string) { + if mask&syscall.IN_Q_OVERFLOW != 0 { + queueWakeup(w.fileChanges) // events were dropped; a cycle will catch whatever changed + return + } + w.watchMu.Lock() + dir, known := w.dirByWatchDescriptor[watchDescriptor] + if mask&syscall.IN_IGNORED != 0 { + delete(w.dirByWatchDescriptor, watchDescriptor) + } + w.watchMu.Unlock() + if !known { + return + } + + path := dir + if name != "" { + path = filepath.Join(dir, name) + } + + if w.spec.IsFileTarget() { + // The watch sits on the parent dir; only the target file counts. + if path != w.spec.Path { + return + } + queueWakeup(w.fileChanges) + return + } + + if strings.Contains(path, "/.git/") || strings.HasSuffix(path, "/.git") { + return + } + if w.spec.Excludes(path) { + return + } + if mask&syscall.IN_ISDIR != 0 && mask&(syscall.IN_CREATE|syscall.IN_MOVED_TO) != 0 { + w.watchTree(path) + } + queueWakeup(w.fileChanges) +} + +// Run one cycle soon regardless of file events (-f at start, resume catch-up). +func (w *repoWatcher) flushNow() { + queueWakeup(w.flushRequests) +} + +func (w *repoWatcher) runCycles() { + defer close(w.stopped) + debounce := time.NewTimer(time.Hour) + debounce.Stop() + retry := time.NewTimer(time.Hour) + retry.Stop() + settle := time.Duration(w.spec.Settle * float64(time.Second)) + for { + select { + case <-w.fileChanges: + // Each change resets the settle timer, so a burst of writes + // lands as one commit (gitwatch's sleep-and-kill loop). + debounce.Reset(settle) + case <-debounce.C: + w.report(autoCommit(w.spec), retry) + case <-w.flushRequests: + w.report(autoCommit(w.spec), retry) + case <-retry.C: + if w.lastError != nil { + w.report(push(w.spec), retry) + } + case <-w.stopping: + debounce.Stop() + retry.Stop() + return + } + } +} + +// Push retry backoff: 30s doubling to a 30 minute cap. +const ( + backoffFirst = 30 * time.Second + backoffCap = 30 * time.Minute +) + +func backoffDelay(afterFailures int) time.Duration { + if afterFailures <= 1 { + return backoffFirst + } + // Compare before converting: past ~30 failures the product overflows + // time.Duration and the conversion result is negative. + d := float64(backoffFirst) * math.Pow(2, float64(afterFailures-1)) + if d > float64(backoffCap) { + return backoffCap + } + return time.Duration(d) +} + +// Fold an outcome into the error state, schedule the next automatic retry, +// and publish. A Clean pass says nothing about an earlier failed push (that +// commit is still unpushed), so it leaves the error and its retry timer alone. +func (w *repoWatcher) report(outcome Outcome, retry *time.Timer) { + switch outcome.Kind { + case Committed, Pushed: + retry.Stop() + w.lastError = nil + case Clean, SkippedMerge: + // no change + case PushFailed: + attempts := 1 + if w.lastError != nil { + attempts = w.lastError.attempts + 1 + } + delay := backoffDelay(attempts) + next := time.Now().Add(delay) + w.lastError = &repoError{outcome: outcome, lastAttempt: time.Now(), + attempts: attempts, nextRetry: &next} + retry.Reset(delay) + case RebaseConflict, CommitFailed: + retry.Stop() + attempts := 1 + if w.lastError != nil { + attempts = w.lastError.attempts + 1 + } + w.lastError = &repoError{outcome: outcome, lastAttempt: time.Now(), + attempts: attempts} + } + w.publishStatus() + w.logOutcome(outcome) +} + +func (w *repoWatcher) publishStatus() { + w.watchMu.Lock() + watchFailure := w.watchFailure + w.watchMu.Unlock() + + var status *RepoStatus + if w.lastError != nil { + status = &RepoStatus{ + ErrorLabel: w.lastError.outcome.ErrorLabel(), + Detail: w.lastError.outcome.Detail, + Attempts: w.lastError.attempts, + LastAttempt: w.lastError.lastAttempt.Unix(), + } + if status.ErrorLabel == "" { + status.ErrorLabel = "failing" + } + if w.lastError.nextRetry != nil { + ts := w.lastError.nextRetry.Unix() + status.NextRetry = &ts + } + } else if watchFailure != "" { + status = &RepoStatus{ErrorLabel: "watch failing", Detail: watchFailure, + Attempts: 1, LastAttempt: time.Now().Unix()} + } + w.onStatus(w.spec.Path, status) +} + +func (w *repoWatcher) logOutcome(outcome Outcome) { + name := w.spec.Name() + switch outcome.Kind { + case Committed: + w.logf("%s: committed", name) + case Pushed: + w.logf("%s: pushed to %s", name, w.spec.Remote) + case SkippedMerge: + w.logf("%s: merge in progress, commit skipped", name) + case CommitFailed: + w.logf("%s: commit failing: %s", name, outcome.Detail) + case RebaseConflict: + w.logf("%s: rebase conflict: %s", name, outcome.Detail) + case PushFailed: + w.logf("%s: push failing: %s", name, outcome.Detail) + } +} + +func (w *repoWatcher) stopWatching() { + close(w.stopping) + syscall.Close(w.inotifyFD) // ends the event reader's blocking read + <-w.stopped +} diff --git a/linux/daemon_test.go b/linux/daemon_test.go new file mode 100644 index 0000000..c138134 --- /dev/null +++ b/linux/daemon_test.go @@ -0,0 +1,569 @@ +package main + +import ( + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// The built gitwatchd binary, for tests that exercise the real daemon. +var testBinary string + +func TestMain(m *testing.M) { + if os.Getenv(stubbornDaemonEnv) != "" { + holdTheDaemonLockIgnoringSIGTERM() + } + dir, err := os.MkdirTemp("", "gitwatchd-bin") + if err == nil { + bin := filepath.Join(dir, "gitwatchd") + build := exec.Command("go", "build", "-o", bin, ".") + build.Stderr = os.Stderr + if build.Run() == nil { + testBinary = bin + } + } + code := m.Run() + if dir != "" { + os.RemoveAll(dir) + } + os.Exit(code) +} + +func runCLI(env []string, args ...string) (int, string) { + return runBinary(testBinary, env, args...) +} + +func runBinary(binary string, env []string, args ...string) (int, string) { + cmd := exec.Command(binary, args...) + cmd.Env = env + out, err := cmd.CombinedOutput() + code := 0 + if exit, ok := err.(*exec.ExitError); ok { + code = exit.ExitCode() + } else if err != nil { + code = -1 + } + return code, strings.TrimSpace(string(out)) +} + +func isolatedEnv(home string) []string { + return append(os.Environ(), + "HOME="+home, + "GITWATCHD_CONFIG="+filepath.Join(home, ".gitwatchd"), + "GITWATCHD_STATE_DIR="+filepath.Join(home, "state"), + "GITWATCHD_NO_SPAWN=1") +} + +func TestDaemonEndToEnd(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := isolatedEnv(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + + if code, out := runCLI(env, "start"); code != 0 || !strings.Contains(out, "✓ daemon started") { + t.Fatalf("start: code=%d out=%s", code, out) + } + if code, out := runCLI(env, "start"); code != 0 || !strings.Contains(out, "daemon already running") { + t.Fatalf("second start: code=%d out=%s", code, out) + } + _, out := runCLI(env, "status") + if !strings.Contains(out, "daemon: running") { + t.Fatalf("status should show the daemon running:\n%s", out) + } + + repo.write("notes.txt", "hello\n") + waitFor(t, 15*time.Second, "the daemon's auto-commit", func() bool { return repo.commitCount() == 1 }) + if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { + t.Errorf("got message %q", repo.lastMessage()) + } + time.Sleep(1 * time.Second) + if repo.commitCount() != 1 { + t.Error("the daemon retriggered on its own commit") + } + + _, out = runCLI(env, "status") + if !strings.Contains(out, "repo · main") { + t.Errorf("status should show the watched row:\n%s", out) + } + + second := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", second.path); code != 0 { + t.Fatalf("second add failed: %s", out) + } + waitForDaemonPickup(t, second, func() { second.write("more.txt", "hi\n") }) + + if code, out := runCLI(env, "pause", repo.path); code != 0 { + t.Fatalf("pause failed: %s", out) + } + time.Sleep(700 * time.Millisecond) // let the reload land + repo.write("while-paused.txt", "quiet\n") + time.Sleep(1200 * time.Millisecond) + if repo.commitCount() != 1 { + t.Error("a paused repo must not commit") + } + if code, out := runCLI(env, "resume", repo.path); code != 0 { + t.Fatalf("resume failed: %s", out) + } + waitFor(t, 15*time.Second, "the resume catch-up commit", func() bool { return repo.commitCount() == 2 }) + + if code, out := runCLI(env, "stop"); code != 0 || !strings.Contains(out, "✓ daemon stopped") { + t.Fatalf("stop: code=%d out=%s", code, out) + } + _, out = runCLI(env, "status") + if !strings.Contains(out, "daemon: not running") { + t.Errorf("status should show the daemon stopped:\n%s", out) + } +} + +// The config reload debounces for 300ms; retry the first write until the +// daemon demonstrably watches the new repo. +func waitForDaemonPickup(t *testing.T, repo *testRepo, change func()) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + change() + for time.Now().Before(deadline) { + if repo.commitCount() >= 1 { + return + } + time.Sleep(500 * time.Millisecond) + change() + } + t.Fatal("the daemon never picked up the newly added repo") +} + +// The one production path the in-process CLI tests cannot reach: `add` +// finding no daemon and spawning one off its own binary (in-process, +// os.Executable() is the go-test binary). Runs without GITWATCHD_NO_SPAWN. +func TestAddSpawnsTheDaemon(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + var env []string + for _, e := range isolatedEnv(home) { + if !strings.HasPrefix(e, "GITWATCHD_NO_SPAWN=") { + env = append(env, e) + } + } + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + waitFor(t, 15*time.Second, "the daemon add spawned", func() bool { + _, out := runCLI(env, "status") + return strings.Contains(out, "daemon: running") + }) + pid := daemonPidIn(home) + if pid <= 0 { + t.Fatal("the spawned daemon wrote no pidfile") + } + + if code, out := runCLI(env, "stop"); code != 0 || !strings.Contains(out, "✓ daemon stopped") { + t.Fatalf("stop: code=%d out=%s", code, out) + } + if err := syscall.Kill(pid, 0); err == nil { + t.Errorf("the spawned daemon (pid %d) is still alive after stop", pid) + } +} + +// This test binary re-executed: holds the pidfile lock and ignores SIGTERM. +const stubbornDaemonEnv = "GITWATCHD_TEST_STUBBORN_DAEMON" + +func holdTheDaemonLockIgnoringSIGTERM() { + signal.Ignore(syscall.SIGTERM) + os.MkdirAll(stateDir(), 0o755) + f, err := os.OpenFile(pidfilePath(), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil || syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { + os.Exit(1) + } + f.Truncate(0) + f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0) + time.Sleep(60 * time.Second) // outlives the test; stop is meant to end this + os.Exit(0) +} + +func TestStopKillsADaemonThatIgnoresSIGTERM(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + stubborn := exec.Command(os.Args[0]) + stubborn.Env = append(isolatedEnv(home), stubbornDaemonEnv+"=1") + if err := stubborn.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { stubborn.Process.Kill() }) + // The pid is written after the lock is taken, so a pid means it is held. + waitFor(t, 10*time.Second, "the stubborn daemon to take the lock", func() bool { + return daemonPidIn(home) > 0 + }) + + code, out := runCLI(isolatedEnv(home), "stop") + if code != 0 { + t.Fatalf("stop must still succeed: code=%d out=%s", code, out) + } + if !strings.Contains(out, "✓ daemon stopped") || !strings.Contains(out, "ignored the stop signal") { + t.Errorf("stop must say what it took to stop it:\n%s", out) + } + exit, killed := stubborn.Wait().(*exec.ExitError) + if !killed { + t.Fatal("the stubborn daemon outlived stop") + } + if status, ok := exit.Sys().(syscall.WaitStatus); !ok || status.Signal() != syscall.SIGKILL { + t.Errorf("expected SIGKILL, got %v", exit) + } +} + +func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + if _, err := os.Stat("/run/systemd/system"); err == nil { + t.Skip("this host runs systemd; the degrade path is exercised in plain containers") + } + home := t.TempDir() + env := envWithoutSystemctl(home) + code, out := runCLI(env, "autostart", "on") + if code == 0 { + t.Error("autostart on must fail without systemd") + } + if !strings.Contains(out, "systemd not found") || !strings.Contains(out, "gitwatchd start") { + 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") + } + if entries, _ := os.ReadDir(home); len(entries) > 2 { // bin/ and nothing else unexpected + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("no dotfiles may be touched, found %v", names) + } +} + +func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := envWithoutSystemctl(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + 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"`) + }) + if log := daemonLogIn(home); !strings.Contains(log, "systemd not found") || + !strings.Contains(log, "gitwatchd start") { + 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") + } + + // The wish is on record now, so a second start has nothing to say. + if code, out := runBinary(installed, env, "stop"); code != 0 { + t.Fatalf("stop: code=%d out=%s", code, out) + } + if code, out := runBinary(installed, env, "start"); code != 0 { + t.Fatalf("second start: code=%d out=%s", code, out) + } + watched := "watching " + filepath.Base(repo.path) + " (" + waitFor(t, 15*time.Second, "the restarted daemon to watch the repo", func() bool { + return strings.Count(daemonLogIn(home), watched) == 2 + }) + 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 { + t.Fatalf("second stop: code=%d out=%s", code, out) + } +} + +// PATH holds the tools gitwatchd runs but no systemctl, so no-systemd paths run on any host. +func envWithoutSystemctl(home string) []string { + bindir := filepath.Join(home, "bin") + os.MkdirAll(bindir, 0o755) + for _, tool := range []string{"git", "date", "sh", "bash"} { + if p, err := lookPathIn(os.Getenv("PATH"), tool); err == nil { + os.Symlink(p, filepath.Join(bindir, tool)) + } + } + env := isolatedEnv(home) + for i, e := range env { + if strings.HasPrefix(e, "PATH=") { + env[i] = "PATH=" + bindir + } + } + return env +} + +func lookPathIn(path, tool string) (string, error) { + for _, dir := range strings.Split(path, ":") { + candidate := filepath.Join(dir, tool) + if info, err := os.Stat(candidate); err == nil && info.Mode()&0o111 != 0 { + return candidate, nil + } + } + return "", os.ErrNotExist +} + +func daemonPidIn(home string) int { + raw, err := os.ReadFile(filepath.Join(home, "state", "gitwatchd.pid")) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) + return pid +} + +func daemonLogIn(home string) string { + raw, _ := os.ReadFile(filepath.Join(home, "state", "daemon.log")) + return string(raw) +} + +func stateFileIn(home string) string { + raw, _ := os.ReadFile(filepath.Join(home, "state", "state.json")) + return string(raw) +} + +func killDaemonIfRunning(home string) { + if pid := daemonPidIn(home); pid > 0 { + syscall.Kill(pid, syscall.SIGKILL) + } +} + +func startWatcher(t *testing.T, spec *RepoSpec) *repoWatcher { + t.Helper() + w, err := newRepoWatcher(spec, func(string, *RepoStatus) {}, func(string, ...any) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(w.stopWatching) + return w +} + +func waitFor(t *testing.T, timeout time.Duration, what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if ok() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func TestWatcherCommitsAfterTheSettleWindow(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2")) + repo.write("notes.txt", "hello\n") + waitFor(t, 10*time.Second, "the auto-commit", func() bool { return repo.commitCount() == 1 }) +} + +func TestWatcherIgnoresItsOwnGitChurn(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2")) + repo.write("notes.txt", "hello\n") + waitFor(t, 10*time.Second, "the auto-commit", func() bool { return repo.commitCount() == 1 }) + // The commit itself churns .git; a retrigger loop would commit again + // (or spin). Nothing should happen now. + time.Sleep(1 * time.Second) + if repo.commitCount() != 1 { + t.Errorf("commits = %d, the watcher retriggered on .git churn", repo.commitCount()) + } +} + +func TestWatcherHonorsExcludes(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2", "-x", `\.log$`)) + repo.write("debug.log", "noise\n") + time.Sleep(1 * time.Second) + if repo.commitCount() != 0 { + t.Error("an excluded change must not trigger a cycle") + } + // A non-excluded change still commits (and sweeps in the .log file, + // exactly like gitwatch: -x filters events, not git add). + repo.write("notes.txt", "hello\n") + waitFor(t, 10*time.Second, "the auto-commit", func() bool { return repo.commitCount() == 1 }) +} + +func TestWatcherSeesNewSubdirectories(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2")) + os.MkdirAll(filepath.Join(repo.path, "fresh", "deep"), 0o755) + time.Sleep(500 * time.Millisecond) // let the new subtree's watches land + repo.write("fresh/deep/inner.txt", "made inside a new directory\n") + waitFor(t, 10*time.Second, "a commit from inside the new subtree", func() bool { + return repo.commitCount() == 1 && pendingCount(repo.path, "") == 0 + }) +} + +func TestWatcherFileTargetSurvivesAtomicSaves(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("b.txt", "not watched\n") + + spec, _ := parseRepoSpec([]string{"-s", "0.2", filepath.Join(repo.path, "a.txt")}) + startWatcher(t, spec) + + // An editor-style atomic save: write a temp file, rename over the target. + tmp := filepath.Join(repo.path, "a.txt.tmp") + os.WriteFile(tmp, []byte("v2\n"), 0o644) + os.Rename(tmp, filepath.Join(repo.path, "a.txt")) + waitFor(t, 10*time.Second, "the file-target commit", func() bool { return repo.commitCount() == 2 }) + if pendingCount(repo.path, "") != 1 { + t.Error("b.txt stays uncommitted, only the file target is committed") + } +} + +func TestWatcherFlushNowCommitsWithoutEvents(t *testing.T) { + repo := newTestRepo(t) + repo.write("pending.txt", "already here\n") + w := startWatcher(t, repo.spec("-s", "0.2")) + w.flushNow() // what -f and a resume catch-up do + waitFor(t, 10*time.Second, "the flush commit", func() bool { return repo.commitCount() == 1 }) +} + +func TestWatchLimitExhaustionSurfacesAsRepoState(t *testing.T) { + repo := newTestRepo(t) + var published *RepoStatus + w, err := newRepoWatcher(repo.spec("-s", "0.2"), + func(_ string, s *RepoStatus) { published = s }, func(string, ...any) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(w.stopWatching) + w.recordWatchFailure("/some/dir", syscall.ENOSPC) + w.publishStatus() + if published == nil || published.ErrorLabel != "watch failing" { + t.Fatalf("got %+v", published) + } + if !strings.Contains(published.Detail, "max_user_watches") { + t.Errorf("the fix should be named: %q", published.Detail) + } +} + +func TestStateFileKeepsSettingsAndRepoStateApart(t *testing.T) { + t.Setenv("GITWATCHD_STATE_DIR", filepath.Join(t.TempDir(), "state")) + setRepoStatus("/repo/one", &RepoStatus{ErrorLabel: "push failing", Attempts: 3}) + setLaunchAtLogin(false) + + s := readState() + if s.LaunchAtLogin != "off" || s.Repos["/repo/one"].Attempts != 3 { + t.Fatalf("recording a setting lost the repo state: %+v", s) + } + setRepoStatus("/repo/one", nil) + if s := readState(); s.LaunchAtLogin != "off" || len(s.Repos) != 0 { + t.Errorf("clearing a repo took the setting with it: %+v", s) + } +} + +// Holding the flock here keeps the binary's `autostart off` waiting until a +// repo status has landed, which its own update then has to preserve. +func TestStateFileWritesAreArbitratedBetweenProcesses(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + stateDirectory := filepath.Join(home, "state") + t.Setenv("GITWATCHD_STATE_DIR", stateDirectory) + os.MkdirAll(stateDirectory, 0o755) + + lock, err := os.OpenFile(filepath.Join(stateDirectory, "state.lock"), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) + } + + recorded := make(chan struct{}) + go func() { + defer close(recorded) + runCLI(isolatedEnv(home), "autostart", "off") + }() + time.Sleep(500 * time.Millisecond) + if strings.Contains(stateFileIn(home), "launch-at-login") { + t.Fatal("the other writer got in while the lock was held") + } + + // By hand: this test already holds the lock updateState would wait for. + s := readState() + s.Repos = map[string]RepoStatus{"/repo/one": {ErrorLabel: "push failing", Attempts: 1}} + writeState(s) + syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + + <-recorded + final := readState() + if final.LaunchAtLogin != "off" { + t.Errorf("the waiting writer's setting never landed: %+v", final) + } + if _, ok := final.Repos["/repo/one"]; !ok { + t.Errorf("the waiting writer dropped the repo state written meanwhile: %+v", final) + } +} diff --git a/linux/git_driver.go b/linux/git_driver.go new file mode 100644 index 0000000..ce16db7 --- /dev/null +++ b/linux/git_driver.go @@ -0,0 +1,247 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Thin wrapper around git. + +// What one auto-commit cycle (or push retry) accomplished. +type CommitOutcome int + +const ( + Clean CommitOutcome = iota // nothing to commit + SkippedMerge // -M: merge in progress, cycle skipped + Committed // committed; no remote configured + Pushed // committed and pushed + CommitFailed // Detail carries the git error line + RebaseConflict // -R: pull --rebase hit a conflict + PushFailed +) + +type Outcome struct { + Kind CommitOutcome + Detail string +} + +// Menu-row label when this outcome is an error state, "" when healthy. +func (o Outcome) ErrorLabel() string { + switch o.Kind { + case PushFailed: + return "push failing" + case RebaseConflict: + return "rebase conflict" + case CommitFailed: + return "commit failing" + } + return "" +} + +func runCommand(exe string, args []string, cwd string) (int, string) { + cmd := exec.Command(exe, args...) + if cwd != "" { + cmd.Dir = cwd + } + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil { + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode(), text + } + return -1, err.Error() + } + return 0, text +} + +func gitRun(args []string, dir string, gitDir string) (int, string) { + full := args + // -g repos get upstream's exact override on every call: + // `git --work-tree $TARGETDIR --git-dir $GIT_DIR `. + if gitDir != "" { + full = append([]string{"--work-tree", dir, "--git-dir", gitDir}, args...) + } + return runCommand("git", full, dir) +} + +func isRepo(dir string, gitDir string) bool { + _, out := gitRun([]string{"rev-parse", "--is-inside-work-tree"}, dir, gitDir) + return out == "true" +} + +func currentBranch(dir string, gitDir string) string { + code, out := gitRun([]string{"rev-parse", "--abbrev-ref", "HEAD"}, dir, gitDir) + if code != 0 { + return "?" + } + return out +} + +func pendingCount(dir string, gitDir string) int { + _, out := gitRun([]string{"status", "--porcelain"}, dir, gitDir) + if out == "" { + return 0 + } + return len(strings.Split(out, "\n")) +} + +func lastCommitSummary(dir string, gitDir string) string { + code, out := gitRun([]string{"log", "-1", "--pretty=%cr · %s"}, dir, gitDir) + if code != 0 { + return "no commits yet" + } + return out +} + +func gitStateExists(names []string, dir string, gitDir string) bool { + code, top := gitRun([]string{"rev-parse", "--git-dir"}, dir, gitDir) + if code != 0 { + return false + } + gitDirPath := top + if !filepath.IsAbs(gitDirPath) { + gitDirPath = filepath.Join(dir, gitDirPath) + } + for _, name := range names { + if _, err := os.Stat(filepath.Join(gitDirPath, name)); err == nil { + return true + } + } + return false +} + +// gitwatch's is_merging: MERGE_HEAD only (a rebase does not count, upstream). +func hasMergeInProgress(dir string, gitDir string) bool { + return gitStateExists([]string{"MERGE_HEAD"}, dir, gitDir) +} + +func hasRebaseInProgress(dir string, gitDir string) bool { + return gitStateExists([]string{"rebase-merge", "rebase-apply"}, dir, gitDir) +} + +// Stage all, commit (honoring -m/-d/-M), then optionally pull --rebase (-R) +// and push (-r/-b). One gitwatch cycle. +func autoCommit(spec *RepoSpec) Outcome { + dir := spec.WorkDir() + if spec.NoMergeCommit && hasMergeInProgress(dir, spec.GitDir) { + return Outcome{Kind: SkippedMerge} + } + if pendingCount(dir, spec.GitDir) == 0 { + return Outcome{Kind: Clean} + } + + // 1. Build the message before add: upstream's order, and -c/-C (not + // ported yet) must see the unstaged tree. Upstream's ${COMMITMSG/\%d/...} + // splices the date into the first %d only. + msg := strings.Replace(spec.Message, "%d", formattedDate(spec.DateFormat), 1) + + // 2. Stage upstream's GIT_ADD_ARGS: "--all ." scoped to the target + // directory, or just the file for a file target. + addTarget := "." + if spec.IsFileTarget() { + addTarget = spec.Path + } + gitRun([]string{"add", "--all", addTarget}, dir, spec.GitDir) + + // 3. Commit. commitOutcome is reporting only; it never gates step 4. + code, out := gitRun([]string{"commit", "-m", msg}, dir, spec.GitDir) + commitOutcome := Outcome{Kind: Committed} + if code != 0 { + // Repo-wide changes outside the watched subtree stage nothing. + if strings.Contains(out, "nothing to commit") || + strings.Contains(out, "nothing added to commit") || + strings.Contains(out, "no changes added to commit") { + commitOutcome = Outcome{Kind: Clean} + } else { + commitOutcome = Outcome{Kind: CommitFailed, Detail: errorSummary(out)} + } + } + + // 4. Pull (-R) and push regardless of the commit result (gitwatch + // parity), so a repo whose commit a hook blocks still pushes what is + // already committed. Without a remote, skip push() entirely: its + // no-remote return would mask a commit failure. + if spec.Remote == "" { + return commitOutcome + } + pushed := push(spec) + if commitOutcome.Kind == CommitFailed { + return commitOutcome + } + return pushed +} + +func push(spec *RepoSpec) Outcome { + if spec.Remote == "" { + return Outcome{Kind: Committed} + } + dir := spec.WorkDir() + pullFailure := "" + if spec.Rebase { + code, out := gitRun([]string{"pull", "--rebase", spec.Remote}, dir, spec.GitDir) + if code != 0 { + pullFailure = errorSummary(out) + } + } + code, out := gitRun(pushArgs(spec.Remote, spec), dir, spec.GitDir) + + if pullFailure != "" { + // A conflict leaves a rebase in progress and needs the user; + // anything else (offline, auth) is transient and worth retrying. + if hasRebaseInProgress(dir, spec.GitDir) { + return Outcome{Kind: RebaseConflict, Detail: pullFailure} + } + return Outcome{Kind: PushFailed, Detail: pullFailure} + } + if code != 0 { + return Outcome{Kind: PushFailed, Detail: errorSummary(out)} + } + return Outcome{Kind: Pushed} +} + +// gitwatch's push command: without -b, a bare `push ` (git's +// push.default decides); with -b, push `:`, or just +// `` from a detached HEAD. +func pushArgs(remote string, spec *RepoSpec) []string { + if spec.Branch == "" { + return []string{"push", remote} + } + code, head := gitRun([]string{"symbolic-ref", "HEAD"}, spec.WorkDir(), spec.GitDir) + if code != 0 { + return []string{"push", remote, spec.Branch} + } + current := strings.Replace(head, "refs/heads/", "", 1) + return []string{"push", remote, current + ":" + spec.Branch} +} + +func errorSummary(out string) string { + var lines []string + for _, l := range strings.Split(out, "\n") { + l = strings.TrimSpace(l) + if l != "" { + lines = append(lines, l) + } + } + for _, l := range lines { + if strings.HasPrefix(l, "error:") || strings.HasPrefix(l, "fatal:") || + strings.HasPrefix(l, "! [rejected]") || strings.HasPrefix(l, "! [remote rejected]") { + return l + } + } + if len(lines) > 0 { + return lines[len(lines)-1] + } + return "unknown git error" +} + +// The -d value passes to date(1) verbatim (the user includes the leading +), to +// match the upstream gitwatch +func formattedDate(fmt string) string { + code, out := runCommand("date", []string{fmt}, "") + if code != 0 { + return "" + } + return out +} diff --git a/linux/git_driver_test.go b/linux/git_driver_test.go new file mode 100644 index 0000000..b05d988 --- /dev/null +++ b/linux/git_driver_test.go @@ -0,0 +1,512 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +type testRepo struct { + t *testing.T + path string +} + +func newTestRepo(t *testing.T) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "repo")} + os.MkdirAll(r.path, 0o755) + r.git("init", "-q", "-b", "main") + r.configureUser() + return r +} + +// A second working copy of a remote (a colleague's machine). +func newCloneOf(t *testing.T, remote *bareRemote) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "clone")} + runCommand("git", []string{"clone", "-q", remote.path, r.path}, "") + r.configureUser() + return r +} + +func (r *testRepo) configureUser() { + r.git("config", "user.email", "tests@gitwatchd.local") + r.git("config", "user.name", "gitwatchd tests") + r.git("config", "commit.gpgsign", "false") +} + +// The spec `gitwatchd add ` would produce. +func (r *testRepo) spec(flags ...string) *RepoSpec { + r.t.Helper() + spec, errMsg := parseRepoSpec(append(flags, r.path)) + if spec == nil { + r.t.Fatalf("spec did not parse: %s", errMsg) + } + return spec +} + +func (r *testRepo) git(args ...string) string { + _, out := gitRun(args, r.path, "") + return out +} + +func (r *testRepo) write(file, contents string) { + r.t.Helper() + full := filepath.Join(r.path, file) + os.MkdirAll(filepath.Dir(full), 0o755) + if err := os.WriteFile(full, []byte(contents), 0o644); err != nil { + r.t.Fatal(err) + } +} + +func (r *testRepo) commitCount() int { + n, _ := strconv.Atoi(r.git("rev-list", "--count", "HEAD")) + return n +} + +func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%s") } + +func (r *testRepo) midMerge() bool { + _, err := os.Stat(filepath.Join(r.path, ".git", "MERGE_HEAD")) + return err == nil +} + +func (r *testRepo) midRebase() bool { + for _, d := range []string{"rebase-merge", "rebase-apply"} { + if _, err := os.Stat(filepath.Join(r.path, ".git", d)); err == nil { + return true + } + } + return false +} + +func (r *testRepo) addOrigin() *bareRemote { + remote := newBareRemote(r.t) + r.git("remote", "add", "origin", remote.path) + return remote +} + +func (r *testRepo) addOriginURL(url string) { + r.git("remote", "add", "origin", url) +} + +func (r *testRepo) setOriginURL(url string) { r.git("remote", "set-url", "origin", url) } + +func (r *testRepo) installFailingPreCommitHook(printing string) { + hook := filepath.Join(r.path, ".git", "hooks", "pre-commit") + os.WriteFile(hook, []byte("#!/bin/sh\necho '"+printing+"'\nexit 1\n"), 0o755) +} + +func (r *testRepo) commit(file, contents, message string) { + r.write(file, contents) + r.git("add", "-A") + r.git("commit", "-q", "-m", message) +} + +// Set main to track origin/main, as a cloned repo would. The branch-less +// `pull --rebase ` needs it. +func (r *testRepo) trackOrigin() { + r.git("fetch", "-q", "origin") + r.git("branch", "-q", "--set-upstream-to=origin/main", "main") +} + +// Stop this repo mid-merge on a real conflict. Plain git only, so tests +// never exercise the engine during their own setup. +func (r *testRepo) conflictedMerge() { + r.commit("f.txt", "base\n", "base") + r.git("checkout", "-q", "-b", "side") + r.commit("f.txt", "side\n", "side edit") + r.git("checkout", "-q", "main") + r.commit("f.txt", "main\n", "main edit") + r.git("merge", "side") // conflicts, leaving MERGE_HEAD behind +} + +type bareRemote struct { + path string +} + +func newBareRemote(t *testing.T) *bareRemote { + t.Helper() + b := &bareRemote{path: filepath.Join(t.TempDir(), "remote.git")} + runCommand("git", []string{"init", "-q", "--bare", "-b", "main", b.path}, "") + return b +} + +func (b *bareRemote) commitCount() int { + code, out := gitRun([]string{"rev-list", "--count", "main"}, b.path, "") + if code != 0 { + return 0 + } + n, _ := strconv.Atoi(out) + return n +} + +func (b *bareRemote) lastMessage() string { + _, out := gitRun([]string{"log", "-1", "--pretty=%s", "main"}, b.path, "") + return out +} + +// Engine tests drive autoCommit / push against real throwaway git repos, +// asserting both the reported outcome and the repo state left behind +// (the gitwatch parity contract: same commands, same end state). + +func TestCleanRepoDoesNothing(t *testing.T) { + repo := newTestRepo(t) + repo.write("seed.txt", "v1") + autoCommit(repo.spec()) // absorb the initial commit + if got := autoCommit(repo.spec()); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Error("no extra commit should appear") + } +} + +func TestChangesCommitLocallyWithoutRemote(t *testing.T) { + repo := newTestRepo(t) + repo.write("notes.txt", "hello") + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Errorf("commits = %d", repo.commitCount()) + } + if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { + t.Errorf("default message expected, got: %s", repo.lastMessage()) + } +} + +func TestCustomMessageAndDateExpansion(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved on %d", "-d", "+%Y")) + if !strings.HasPrefix(repo.lastMessage(), "saved on 2") { // "saved on 2026" + t.Errorf("got: %s", repo.lastMessage()) + } +} + +// Sharp corner, kept for upstream parity: -d goes to date(1) raw, so a +// format without a leading + splices an empty date. Git's commit-message +// cleanup then trims the trailing whitespace. +func TestSharpCornerRawDateFormatWithoutPlusSplicesEmptyDate(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "at %d", "-d", "%Y")) + if repo.lastMessage() != "at" { + t.Errorf("got %q, want %q", repo.lastMessage(), "at") + } +} + +func TestOnlyTheFirstDateTokenIsExpanded(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved %d then %d", "-d", "+%Y")) + got := repo.lastMessage() + if !strings.HasPrefix(got, "saved 2") || !strings.HasSuffix(got, " then %d") { + t.Errorf("upstream splices the date into the first %%d only, got: %s", got) + } +} + +func TestPushToRemote(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the remote should have the commit") + } +} + +func TestUnreachableRemoteReportsPushFailedButCommitSurvives(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "not-a-remote.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if got.Detail == "" { + t.Error("the git error is captured for status") + } + if repo.commitCount() != 1 { + t.Error("gitwatch parity: commit stays, only the push failed") + } +} + +func TestPushRetriesStrandedCommitAfterOutage(t *testing.T) { + repo := newTestRepo(t) + origin := newBareRemote(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "offline.git")) // remote "down" + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != PushFailed { + t.Fatalf("setup: expected the first push to fail, got %+v", got) + } + repo.setOriginURL(origin.path) // remote "back up" + if got := push(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the earlier commit reached the remote") + } +} + +func TestPullFailureWhileOfflineIsTransientNotConflict(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if repo.midRebase() { + t.Error("no rebase was ever started, so the retry loop may heal this") + } +} + +func TestIdempotentRetryStillReportsPushed(t *testing.T) { + repo := newTestRepo(t) + repo.addOrigin() + repo.write("a.txt", "1") + spec := repo.spec("-r", "origin", "-b", "main") + autoCommit(spec) + if got := push(spec); got.Kind != Pushed { // "Everything up-to-date" + t.Errorf("got %+v", got) + } +} + +func TestPushFormWithoutBranch(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-r", "origin", "/tmp/x"}) + got := pushArgs("origin", spec) + if strings.Join(got, " ") != "push origin" { + t.Errorf("got %v", got) + } +} + +func TestPushFormWithBranch(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "-b", "feature") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin feature:main" { + t.Errorf("got %v", got) + } +} + +func TestPushFormFromDetachedHead(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "--detach") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin main" { + t.Errorf("got %v", got) + } +} + +func TestRefspecEndToEnd(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.git("checkout", "-q", "-b", "feature") + repo.write("b.txt", "on feature\n") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-m", "from feature")); got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 2 { + t.Error("the remote's main received the feature commit") + } + if origin.lastMessage() != "from feature" { + t.Errorf("got %q", origin.lastMessage()) + } +} + +func TestRebaseThenPush(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("ours.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) + colleague.write("theirs.txt", "from the other machine\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main", "-m", "made elsewhere")) + + repo.write("ours.txt", "updated here\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R", "-m", "made here")) + if got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 3 { + t.Error("base, theirs, ours: one linear history") + } + if origin.lastMessage() != "made here" { + t.Error("our commit was rebased on top") + } + if !strings.Contains(repo.git("log", "--pretty=%s"), "made elsewhere") { + t.Error("their commit is now part of our local history") + } +} + +func TestMergeGuardSkipsMidMerge(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if !repo.midMerge() { + t.Fatal("setup: repo should be mid-merge") + } + if got := autoCommit(repo.spec("-M")); got.Kind != SkippedMerge { + t.Errorf("got %+v", got) + } + if !repo.midMerge() { + t.Error("the merge is left exactly as it was") + } +} + +func TestWithoutMergeGuardTheMergeIsCommitted(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.midMerge() { + t.Error("the commit concluded the merge, as gitwatch would") + } +} + +func TestSubdirectoryTargetCommitsOnlyTheSubtree(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("outer.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "sub/inner.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestFileTargetCommitsOnlyThatFile(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "a.txt")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("b.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "a.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestOutsideChangesOnlyReportClean(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("outer.txt", "elsewhere\n") + before := repo.commitCount() + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != before { + t.Error("no commit should appear") + } +} + +func TestDetachedGitDir(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "base\n") + autoCommit(repo.spec()) + gitDir := filepath.Join(t.TempDir(), "elsewhere.git") + if err := os.Rename(filepath.Join(repo.path, ".git"), gitDir); err != nil { + t.Fatal(err) + } + spec := repo.spec("-g", gitDir) + if !isRepo(repo.path, gitDir) { + t.Error("the daemon/CLI validation path must accept a -g repo") + } + repo.write("a.txt", "changed\n") + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if _, out := gitRun([]string{"rev-list", "--count", "HEAD"}, repo.path, gitDir); out != "2" { + t.Errorf("commits = %s", out) + } + if got := autoCommit(spec); got.Kind != Clean { + t.Error("and the change was fully committed") + } +} + +func TestFailingPreCommitHookReportsCommitFailed(t *testing.T) { + repo := newTestRepo(t) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "1") + got := autoCommit(repo.spec()) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } +} + +// The push still runs after a failed commit (gitwatch parity), but its result +// must not mask the commit failure: the menu/status row has to say "commit +// failing", not "pushed". +func TestFailingCommitIsStillReportedWhenThePushSucceeds(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("seed.txt", "1") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "2") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } + if origin.commitCount() != 1 || repo.commitCount() != 1 { + t.Error("the blocked commit never happened, and the push had nothing new to send") + } +} + +func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("shared.txt", "original\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) // someone else pushes first + colleague.write("shared.txt", "colleague's version\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main")) + + repo.write("shared.txt", "our conflicting version\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != RebaseConflict { + t.Fatalf("expected rebaseConflict, got %+v", got) + } + // gitwatch parity: no abort. The conflicted rebase is left in progress + // for the user to resolve; we only make it visible in status. + if !repo.midRebase() { + t.Error("the conflicted rebase is left in progress") + } +} diff --git a/linux/gitwatch_parity_test.go b/linux/gitwatch_parity_test.go new file mode 100644 index 0000000..d7692bb --- /dev/null +++ b/linux/gitwatch_parity_test.go @@ -0,0 +1,316 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Differential parity tests: the same scenario runs through upstream +// gitwatch.sh (the model) and through our engine, each on its own +// identically-built world, and the observable git state afterwards must be +// identical. Scenario setup uses only plain git commands, so neither +// implementation touches the world until the measured cycle. +// +// Determinism: gitwatch's -f performs one commit cycle before entering its +// watch loop, and GW_INW_BIN lets us point the watcher at a stub that exits +// immediately, so the script does exactly one cycle and terminates. Every +// scenario passes an explicit -m without %d, since default messages and +// timestamps intentionally differ. + +// Everything a user could observe about a world after one cycle. +type repoState struct { + commitCount int + lastMessage string + pendingChanges int + branch string + midMerge bool + midRebase bool + remoteCommitCount int // -1 when the scenario has no remote + remoteLastMessage string +} + +func (s repoState) String() string { + return fmt.Sprintf("commits=%d last=%q pending=%d branch=%s midMerge=%v midRebase=%v remote(commits=%d last=%q)", + s.commitCount, s.lastMessage, s.pendingChanges, s.branch, + s.midMerge, s.midRebase, s.remoteCommitCount, s.remoteLastMessage) +} + +func stateOf(repo *testRepo, remote *bareRemote) repoState { + s := repoState{ + commitCount: repo.commitCount(), + lastMessage: repo.lastMessage(), + pendingChanges: pendingCount(repo.path, ""), + branch: currentBranch(repo.path, ""), + midMerge: repo.midMerge(), + midRebase: repo.midRebase(), + remoteCommitCount: -1, + } + if remote != nil { + s.remoteCommitCount = remote.commitCount() + s.remoteLastMessage = remote.lastMessage() + } + return s +} + +// The upstream script, vendored once for the whole repo in oracle/; both +// implementations measure themselves against that same copy. +func gitwatchScript(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + script := filepath.Join(wd, "..", "oracle", "gitwatch.sh") + if _, err := os.Stat(script); err != nil { + t.Fatalf("vendored gitwatch.sh not found at %s", script) + } + return script +} + +// A watcher stub that exits immediately: gitwatch runs its -f startup +// commit, the watch pipe hits EOF, and the script terminates. +func stubWatcher(t *testing.T) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "inotifywait") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + return stub +} + +// Run upstream gitwatch for exactly one commit cycle on `target`. +func runOneCycle(t *testing.T, flags []string, target string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + args := append([]string{gitwatchScript(t), "-f"}, flags...) + args = append(args, target) + cmd := exec.CommandContext(ctx, "bash", args...) + cmd.Env = append(os.Environ(), "GW_INW_BIN="+stubWatcher(t)) + cmd.Run() // fire-and-forget, like gitwatch itself +} + +// Build two identical worlds, run the model on one and our engine on the +// other with the same flags, and return both fingerprints. +func twins(t *testing.T, flags []string, withRemote bool, targetSuffix string, + setup func(*testRepo, *bareRemote)) (model, ours repoState) { + t.Helper() + target := func(repo *testRepo) string { + if targetSuffix != "" { + return filepath.Join(repo.path, targetSuffix) + } + return repo.path + } + + a := newTestRepo(t) + var ra *bareRemote + if withRemote { + ra = a.addOrigin() + } + setup(a, ra) + runOneCycle(t, flags, target(a)) + model = stateOf(a, ra) + + b := newTestRepo(t) + var rb *bareRemote + if withRemote { + rb = b.addOrigin() + } + setup(b, rb) + spec, errMsg := parseRepoSpec(append(flags, target(b))) + if spec == nil { + t.Fatal(errMsg) + } + autoCommit(spec) + ours = stateOf(b, rb) + return model, ours +} + +func expectParity(t *testing.T, model, ours repoState) { + t.Helper() + if model != ours { + t.Errorf("state diverged\n gitwatch: %v\n ours: %v", model, ours) + } +} + +// Plain-git scenario builders (no engine involvement). +func seed(repo *testRepo) { + repo.write("seed.txt", "seed\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") +} + +func seedAndPush(repo *testRepo) { + seed(repo) + repo.git("push", "-q", "origin", "main") + repo.trackOrigin() +} + +func colleaguePushes(t *testing.T, remote *bareRemote, file, message string) { + colleague := newCloneOf(t, remote) + colleague.write(file, "from the other machine\n") + colleague.git("add", "-A") + colleague.git("commit", "-q", "-m", message) + colleague.git("push", "-q", "origin", "main") +} + +func TestParityCleanRepo(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + }) + expectParity(t, model, ours) + if ours.commitCount != 1 { + t.Errorf("neither side commits anything: %v", ours) + } +} + +func TestParityPlainCommit(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.lastMessage != "cycle" || ours.pendingChanges != 0 { + t.Errorf("same commit, same message, clean tree afterwards: %v", ours) + } +} + +// Sharp corner, kept for upstream parity: -d passes to date(1) raw and only +// the first %d is spliced. The year-only format keeps the spliced date +// identical across the two runs, so the fingerprints compare exactly. +func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { + model, ours := twins(t, []string{"-m", "at %d then %d", "-d", "+%Y"}, false, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if !strings.HasPrefix(ours.lastMessage, "at 2") || !strings.HasSuffix(ours.lastMessage, " then %d") { + t.Errorf("got %q", ours.lastMessage) + } +} + +func TestParityPushToRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seedAndPush(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 2 || ours.remoteLastMessage != "cycle" { + t.Errorf("both push the commit: %v", ours) + } +} + +func TestParityUnreachableRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.setOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 { + t.Error("fire-and-forget: the commit still happens") + } +} + +// Upstream runs the pull and the push after `git commit` whether or not the +// commit succeeded, so a repo whose commit a hook blocks still pushes what is +// already committed. The seed here is deliberately left unpushed, which is what +// lets the two worlds disagree if we ever short-circuit on a failed commit. +func TestParityFailedCommitStillPushes(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) // committed locally, never pushed + repo.installFailingPreCommitHook("lint says no") + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 1 { + t.Errorf("the already-committed seed reaches the remote on both sides: %v", ours) + } + if ours.commitCount != 1 || ours.pendingChanges == 0 { + t.Errorf("the hook blocked the new commit on both sides: %v", ours) + } +} + +func TestParityMergeGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-M"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if !ours.midMerge { + t.Error("the merge is left untouched on both sides") + } +} + +func TestParityMergeCommittedWithoutGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if ours.midMerge { + t.Error("the cycle concluded the merge on both sides") + } +} + +func TestParityRebaseHappyPath(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "theirs.txt", "made elsewhere") + repo.write("ours.txt", "made here\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 3 { + t.Error("seed, theirs, ours: one linear history") + } + if ours.remoteLastMessage != "cycle" { + t.Errorf("got %q", ours.remoteLastMessage) + } +} + +func TestParitySubdirectoryTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "sub", func(repo *testRepo, _ *bareRemote) { + repo.write("sub/inner.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("outer.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityFileTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { + repo.write("a.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("b.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityRebaseConflictLeftInProgress(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "seed.txt", "conflicting edit") + repo.write("seed.txt", "our conflicting edit\n") + }) + expectParity(t, model, ours) + if !ours.midRebase { + t.Error("both sides stop mid-rebase; -M is the only guard") + } +} diff --git a/linux/main.go b/linux/main.go deleted file mode 100644 index 0d89a27..0000000 --- a/linux/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -func main() { - fmt.Fprintln(os.Stderr, "gitwatchd: not implemented on Linux yet") - os.Exit(1) -} diff --git a/linux/utils.go b/linux/utils.go new file mode 100644 index 0000000..cc0c364 --- /dev/null +++ b/linux/utils.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "math" +) + +// Pure formatting helpers, with no gitwatchd vocabulary of their own. + +func truncated(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-1]) + "…" +} + +func ago(seconds float64) string { + if seconds < 5 { + return "just now" + } + return span(seconds) + " ago" +} + +func span(seconds float64) string { + s := int(math.Round(seconds)) + if s < 1 { + s = 1 + } + if s < 90 { + return fmt.Sprintf("%ds", s) + } + if s < 90*60 { + return fmt.Sprintf("%dm", int(math.Round(float64(s)/60))) + } + if s < 36*3600 { + return fmt.Sprintf("%dh", int(math.Round(float64(s)/3600))) + } + return fmt.Sprintf("%dd", int(math.Round(float64(s)/86400))) +} diff --git a/macos/Tests/ParityTests.swift b/macos/Tests/ParityTests.swift index b0016ac..8e8f6ad 100644 --- a/macos/Tests/ParityTests.swift +++ b/macos/Tests/ParityTests.swift @@ -49,7 +49,7 @@ enum GitwatchReference { /// tests work regardless of the working directory. static let script = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() - .appendingPathComponent("Reference/gitwatch.sh").path + .appendingPathComponent("../../oracle/gitwatch.sh").path /// A watcher stub that exits immediately: gitwatch runs its -f startup /// commit, the watch pipe hits EOF, and the script terminates. diff --git a/macos/Tests/Reference/README.md b/oracle/README.md similarity index 70% rename from macos/Tests/Reference/README.md rename to oracle/README.md index 5686691..c6c1e13 100644 --- a/macos/Tests/Reference/README.md +++ b/oracle/README.md @@ -1,9 +1,8 @@ # Upstream reference copy `gitwatch.sh` is a verbatim copy of upstream gitwatch, used as the model in the -differential parity tests (`Tests/ParityTests.swift`): the same scenario runs -through this script and through our engine, and the resulting git state must -be identical. +differential parity tests of both implementations +(`macos/Tests/ParityTests.swift` and `linux/gitwatch_parity_test.go`). - Source: https://raw.githubusercontent.com/gitwatch/gitwatch/master/gitwatch.sh - Fetched: 2026-07-18 diff --git a/macos/Tests/Reference/gitwatch.sh b/oracle/gitwatch.sh similarity index 100% rename from macos/Tests/Reference/gitwatch.sh rename to oracle/gitwatch.sh From 4f13919b6824a31e3730192f3b1c16318bfae598 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 18:05:35 +0100 Subject: [PATCH 4/6] Port -l/-L, -c/-C and -v to the Linux daemon (#10) --- linux/cli.go | 73 +++++++--- linux/cli_test.go | 64 ++++++++- linux/git_driver.go | 128 ++++++++++++++++- linux/git_driver_test.go | 249 +++++++++++++++++++++++++++++++++- linux/gitwatch_parity_test.go | 157 +++++++++++++++++++++ 5 files changed, 645 insertions(+), 26 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index 5095599..8b0da31 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -62,6 +62,16 @@ FLAGS (for add) Default: "gitwatchd auto-commit (%d)". -d Format string for that timestamp (see ` + "`man date`" + `). Default: "+%Y-%m-%d %H:%M:%S". + -l Use the diff itself as the commit message (file:line: change, + in colour), up to lines (0 = no limit). A diff + longer than falls back to the ` + "`git diff --stat`" + ` + summary. Overrides -m. + -L Same as -l, without colour. Known bug: on git versions > 2.39 + this falls back to a status summary. + -c Run and use its output as the commit message. + Overrides -m and -d. + -C Pipe the changed file names into the -c command's stdin. + Requires -c flag, e.g. ` + "`gitwatchd -c 'xargs echo updated:' -C .`" + ` -x Skip changes whose path matches this regular expression (e.g. '\.log$' or 'build/'). -M Skip committing while the repo has a merge in progress. @@ -84,24 +94,29 @@ type ConfigError struct { // A watched-repo specification, expressed in gitwatch's flag vocabulary. // // gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg] -// [-x pattern] [-M] [-g gitdir] [-e events] +// [-c cmd] [-C] [-l|-L lines] [-x pattern] [-M] [-g gitdir] +// [-e events] // // Each config line is exactly the argument string you'd pass to gitwatch (modulo path resolution). type RepoSpec struct { - Path string - Settle float64 // -s debounce seconds - DateFormat string // -d - Remote string // -r - Branch string // -b - Rebase bool // -R pull --rebase before push - Message string // -m (%d -> date) - Exclude string // -x regex; last one wins, as upstream - NoMergeCommit bool // -M - CommitOnStart bool // -f commit pending changes when watching starts - GitDir string // -g --git-dir - Paused bool // --paused (gitwatchd extension, not gitwatch) - Raw string // the config line this spec came from, for change detection - targetIndex int // which arg was the target, so add can persist it resolved + Path string + Settle float64 // -s debounce seconds + DateFormat string // -d + Remote string // -r + Branch string // -b + Rebase bool // -R pull --rebase before push + Message string // -m (%d -> date) + CommitCommand string // -c its stdout becomes the commit message + PipeChangedFiles bool // -C pipe changed file names to the -c command + ListChanges int // -l/-L diff lines allowed in the message; -1 off, 0 unlimited + ListChangesColor bool // false once -L appears; -l never restores it, as upstream + Exclude string // -x regex; last one wins, as upstream + NoMergeCommit bool // -M + CommitOnStart bool // -f commit pending changes when watching starts + GitDir string // -g --git-dir + Paused bool // --paused (gitwatchd extension, not gitwatch) + Raw string // the config line this spec came from, for change detection + targetIndex int // which arg was the target, so add can persist it resolved } func (s RepoSpec) Name() string { @@ -764,9 +779,11 @@ func tokenize(line string) []string { // Returns nil + an error message if there's no valid target. func parseRepoSpec(args []string) (*RepoSpec, string) { spec := &RepoSpec{ - Settle: 2, - DateFormat: "+%Y-%m-%d %H:%M:%S", - Message: "gitwatchd auto-commit (%d)", + Settle: 2, + DateFormat: "+%Y-%m-%d %H:%M:%S", + Message: "gitwatchd auto-commit (%d)", + ListChanges: -1, + ListChangesColor: true, } target := "" haveTarget := false @@ -807,6 +824,24 @@ func parseRepoSpec(args []string) (*RepoSpec, string) { if v, ok := next(); ok { spec.Message = v } + case "-c": + if v, ok := next(); ok { + spec.CommitCommand = v + } + case "-C": + // Boolean, as on macOS: upstream declares `C:` yet ignores OPTARG, + // so its documented `-c cmd -C ` swallows the target. + spec.PipeChangedFiles = true + case "-l", "-L": + v, ok := next() + n, err := strconv.Atoi(v) + if !ok || err != nil || n < 0 { + return nil, a + " needs a number of lines, 0 or more" + } + spec.ListChanges = n + if a == "-L" { + spec.ListChangesColor = false // -l never restores colour, as upstream + } case "-x": v, ok := next() if !ok { @@ -826,6 +861,8 @@ func parseRepoSpec(args []string) (*RepoSpec, string) { } case "-e": next() // -e accepted for gitwatch compatibility; ignored + case "-v": + // verbose: accepted, no-op (gitwatch's -v turns on bash tracing) case "--paused": spec.Paused = true default: diff --git a/linux/cli_test.go b/linux/cli_test.go index cc5dc22..898ee96 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -405,6 +405,12 @@ func TestDefaultsForBarePath(t *testing.T) { if spec.CommitOnStart { t.Error("-f is opt-in: a deliberate manual state is not flushed") } + if spec.CommitCommand != "" || spec.PipeChangedFiles { // -c "Overrides -m and -d"; -C off + t.Errorf("unexpected non-default: %+v", spec) + } + if spec.ListChanges != -1 || !spec.ListChangesColor { + t.Error("-l/-L off by default; the -m message is used") + } } func TestPIsAnAliasOfR(t *testing.T) { @@ -430,6 +436,61 @@ func TestSettleRejectsBadValues(t *testing.T) { } } +func TestVerboseIsAcceptedAndIgnored(t *testing.T) { + spec, msg := parseRepoSpec([]string{"-v", "/tmp/x"}) + if spec == nil || spec.Path != "/tmp/x" { + t.Errorf("-v is accepted for gitwatch parity, then ignored: %s", msg) + } +} + +func TestListChangesFlagsSetTheCapAndColour(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-l", "10", "/tmp/x"}) + if spec.ListChanges != 10 || !spec.ListChangesColor { + t.Errorf("got %+v", spec) + } + spec, _ = parseRepoSpec([]string{"-L", "10", "/tmp/x"}) + if spec.ListChanges != 10 || spec.ListChangesColor { + t.Errorf("-L is -l without colour, got %+v", spec) + } + spec, _ = parseRepoSpec([]string{"-L", "5", "-l", "3", "/tmp/x"}) + if spec.ListChanges != 3 { + t.Errorf("the line cap itself is last-wins, got %+v", spec) + } + if spec.ListChangesColor { + t.Error("upstream's -l never restores colour once -L appeared") + } +} + +func TestListChangesRejectsBadLineCounts(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-l", "many", "/tmp/x"}); spec != nil { + t.Error("-l many should be rejected") + } + if spec, _ := parseRepoSpec([]string{"-L", "-1", "/tmp/x"}); spec != nil { + t.Error("-L -1 should be rejected") + } + if spec, _ := parseRepoSpec([]string{"-l", "0", "/tmp/x"}); spec == nil || spec.ListChanges != 0 { + t.Error("-l 0 is valid and means unlimited") + } +} + +func TestCommitCommandIsStoredVerbatim(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-c", "echo a b", "-C", "/tmp/x"}) + if spec == nil || spec.CommitCommand != "echo a b" || !spec.PipeChangedFiles { + t.Errorf("got %+v", spec) + } +} + +// Deliberate divergence from upstream, matching macOS: gitwatch's getopts +// declares -C as taking an argument, so there `-c cmd -C ` loses the +// target. Treating -C as the boolean its body implies keeps every documented +// invocation working. +func TestPipeFlagConsumesNoArgument(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-C", "-r", "origin", "/tmp/x"}) + if spec == nil || !spec.PipeChangedFiles || spec.Remote != "origin" || spec.Path != "/tmp/x" { + t.Errorf("got %+v", spec) + } +} + func TestExcludeMatchesAnywhereInPath(t *testing.T) { spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "/tmp/x"}) if !spec.Excludes("/tmp/x/deep/dir/debug.log") { @@ -480,9 +541,10 @@ func TestNoExcludeByDefault(t *testing.T) { var contractValueFlags = map[string]string{ "-s": "2", "-r": "origin", "-b": "main", "-m": "msg", "-d": "+%Y", "-x": `\.log$`, "-g": "/tmp/gd", + "-l": "10", "-L": "10", "-c": "echo hi", } -var contractBoolFlags = []string{"-R", "-M", "-f", "--paused"} +var contractBoolFlags = []string{"-R", "-M", "-f", "-C", "--paused"} var contractCommands = []string{"add", "rm", "pause", "resume", "status", "start", "stop", "autostart", "config", "help", "version"} diff --git a/linux/git_driver.go b/linux/git_driver.go index ce16db7..f5faa33 100644 --- a/linux/git_driver.go +++ b/linux/git_driver.go @@ -1,9 +1,12 @@ package main import ( + "bytes" "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" ) @@ -66,6 +69,22 @@ func gitRun(args []string, dir string, gitDir string) (int, string) { return runCommand("git", full, dir) } +// $() capture: stdout only, trailing newlines stripped, leading whitespace kept. +func gitCapture(args []string, dir string, gitDir string) string { + return strings.TrimRight(string(gitCaptureRaw(args, dir, gitDir)), "\n") +} + +func gitCaptureRaw(args []string, dir string, gitDir string) []byte { + full := args + if gitDir != "" { + full = append([]string{"--work-tree", dir, "--git-dir", gitDir}, args...) + } + cmd := exec.Command("git", full...) + cmd.Dir = dir + out, _ := cmd.Output() // stdout even when git failed, as command substitution keeps + return out +} + func isRepo(dir string, gitDir string) bool { _, out := gitRun([]string{"rev-parse", "--is-inside-work-tree"}, dir, gitDir) return out == "true" @@ -121,8 +140,103 @@ func hasRebaseInProgress(dir string, gitDir string) bool { return gitStateExists([]string{"rebase-merge", "rebase-apply"}, dir, gitDir) } -// Stage all, commit (honoring -m/-d/-M), then optionally pull --rebase (-R) -// and push (-r/-b). One gitwatch cycle. +var ( + removedFileHeader = regexp.MustCompile(`--- (?:a/)?([^ \t\x1b]+)`) + addedFileHeader = regexp.MustCompile(`\+\+\+ (?:b/)?([^ \t\x1b]+)`) + hunkHeader = regexp.MustCompile(`@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,[0-9]+)? @@`) + changedLine = regexp.MustCompile(`^(?:\x1b\[[0-9;]+m)*([ +-])`) +) + +// Bash's ${s:0:n}: bytes that are not valid UTF-8 count as one character each. +func cutToChars(s string, n int) string { + count := 0 + for i := range s { + if count == n { + return s[:i] + } + count++ + } + return s +} + +// Upstream's diff-lines filter: hunk lines become "path:line: content". +func diffLines(diff string) string { + path, line, previousPath := "", "", "" + var out []string + for _, reply := range strings.Split(diff, "\n") { + if m := removedFileHeader.FindStringSubmatch(reply); m != nil { + previousPath = m[1] + } else if m := addedFileHeader.FindStringSubmatch(reply); m != nil { + path = m[1] + } else if m := hunkHeader.FindStringSubmatch(reply); m != nil { + line = m[1] + } else if m := changedLine.FindStringSubmatch(reply); m != nil { + reply = cutToChars(reply, 150) + if path == "/dev/null" { + out = append(out, "File "+previousPath+" deleted or moved.") + continue + } + out = append(out, path+":"+line+": "+reply) + if m[1] != "-" { + n, _ := strconv.Atoi(line) + line = strconv.Itoa(n + 1) + } + } + } + return strings.Join(out, "\n") +} + +// -L's empty colour argument trips git > 2.39: the known bug the help text documents. +func listChangesMessage(spec *RepoSpec) string { + dir := spec.WorkDir() + colorArg := "" + if spec.ListChangesColor { + colorArg = "--color=always" + } + msg := diffLines(gitCapture([]string{"diff", "-U0", colorArg}, dir, spec.GitDir)) + length := 0 + if spec.ListChanges >= 1 && msg != "" { + length = len(strings.Split(msg, "\n")) + } + if length <= spec.ListChanges { + if msg != "" { + return msg + } + return "New files added: " + gitCapture([]string{"status", "-s"}, dir, spec.GitDir) + } + var stat []string + for _, l := range strings.Split(gitCapture([]string{"diff", "--stat"}, dir, spec.GitDir), "\n") { + if strings.Contains(l, "|") { + stat = append(stat, l) + } + } + return strings.Join(stat, "\n") +} + +// Word-split into an argv, no shell; stdout is the message, exit status ignored. +func commitCommandOutput(spec *RepoSpec) string { + argv := strings.FieldsFunc(spec.CommitCommand, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' // bash's default IFS + }) + if len(argv) == 0 { + return "" + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Dir = spec.WorkDir() + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = os.Stderr // upstream leaves the command's stderr on the terminal + if spec.PipeChangedFiles { + cmd.Stdin = bytes.NewReader(gitCaptureRaw([]string{"diff", "--name-only"}, spec.WorkDir(), spec.GitDir)) + } + cmd.Run() + // $() drops NUL bytes and trailing newlines. + out := bytes.ReplaceAll(stdout.Bytes(), []byte{0}, nil) + return strings.TrimRight(string(out), "\n") +} + +// Stage all, commit (honoring -m/-d/-l/-L/-c/-C/-M), then optionally pull +// --rebase (-R) and push (-r/-b). One gitwatch cycle. func autoCommit(spec *RepoSpec) Outcome { dir := spec.WorkDir() if spec.NoMergeCommit && hasMergeInProgress(dir, spec.GitDir) { @@ -132,10 +246,14 @@ func autoCommit(spec *RepoSpec) Outcome { return Outcome{Kind: Clean} } - // 1. Build the message before add: upstream's order, and -c/-C (not - // ported yet) must see the unstaged tree. Upstream's ${COMMITMSG/\%d/...} - // splices the date into the first %d only. + // 1. Message before add, so -l/-L and -c/-C see the unstaged tree; each overrides the last. msg := strings.Replace(spec.Message, "%d", formattedDate(spec.DateFormat), 1) + if spec.ListChanges >= 0 { + msg = listChangesMessage(spec) + } + if spec.CommitCommand != "" { + msg = commitCommandOutput(spec) + } // 2. Stage upstream's GIT_ADD_ARGS: "--all ." scoped to the target // directory, or just the file for a file target. diff --git a/linux/git_driver_test.go b/linux/git_driver_test.go index b05d988..56a23d9 100644 --- a/linux/git_driver_test.go +++ b/linux/git_driver_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "strconv" @@ -66,7 +67,8 @@ func (r *testRepo) commitCount() int { return n } -func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%s") } +// %B, not %s: -l/-L and -c messages are multi-line. +func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%B") } func (r *testRepo) midMerge() bool { _, err := os.Stat(filepath.Join(r.path, ".git", "MERGE_HEAD")) @@ -144,7 +146,7 @@ func (b *bareRemote) commitCount() int { } func (b *bareRemote) lastMessage() string { - _, out := gitRun([]string{"log", "-1", "--pretty=%s", "main"}, b.path, "") + _, out := gitRun([]string{"log", "-1", "--pretty=%B", "main"}, b.path, "") return out } @@ -209,6 +211,231 @@ func TestOnlyTheFirstDateTokenIsExpanded(t *testing.T) { } } +func numberedLines(prefix string, n int) string { + var lines []string + for i := 1; i <= n; i++ { + lines = append(lines, fmt.Sprintf("%s%d", prefix, i)) + } + return strings.Join(lines, "\n") + "\n" +} + +func TestListChangesEmbedsTheColouredDiff(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "alpha\n") + autoCommit(repo.spec()) + repo.write("a.txt", "beta\n") + if got := autoCommit(repo.spec("-l", "10", "-m", "unused")); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + // Exact bytes vary with git version and colour config, so this asserts + // structure plus the presence of escapes; the parity suite pins the exact + // bytes differentially against the same git. + if !strings.Contains(repo.lastMessage(), "\x1b[") { + t.Errorf("-l keeps git's colour codes, got: %q", repo.lastMessage()) + } + if !strings.Contains(repo.lastMessage(), "a.txt:1: ") { + t.Errorf("hunk lines carry path:line:, got: %q", repo.lastMessage()) + } +} + +func TestListChangesCutsEachLineAt150Chars(t *testing.T) { + repo := newTestRepo(t) + repo.write("long.txt", "short\n") + autoCommit(repo.spec()) + repo.write("long.txt", strings.Repeat("x", 200)+"\n") + autoCommit(repo.spec("-l", "0")) + if !strings.Contains(repo.lastMessage(), strings.Repeat("x", 100)) { + t.Errorf("the long line is embedded, got: %q", repo.lastMessage()) + } + if strings.Contains(repo.lastMessage(), strings.Repeat("x", 150)) { + t.Error("cut at 150 chars of raw diff line (colour codes count)") + } + for _, line := range strings.Split(repo.lastMessage(), "\n") { + if len([]rune(line)) > len("long.txt:1: ")+150 { + t.Errorf("over-long line: %q", line) + } + } +} + +func TestListChangesZeroMeansUnlimited(t *testing.T) { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-l", "0")) + lines := strings.Split(repo.lastMessage(), "\n") + if len(lines) != 20 { + t.Fatalf("10 removals + 10 additions, got: %q", repo.lastMessage()) + } + if !strings.HasPrefix(lines[0], "n.txt:1: ") || !strings.Contains(lines[0], "-old1") { + t.Errorf("removals keep the hunk's start line, got: %q", lines[0]) + } + last := lines[19] + if !strings.HasPrefix(last, "n.txt:10: ") || !strings.Contains(last, "new10") { + t.Errorf("additions advance the line number, got: %q", last) + } +} + +func TestListChangesAtExactlyTheCapStillEmbeds(t *testing.T) { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-l", "20")) // the cap is "more than", not "at least" + if n := len(strings.Split(repo.lastMessage(), "\n")); n != 20 { + t.Errorf("20 diff lines fit in -l 20, got %d: %q", n, repo.lastMessage()) + } +} + +func TestListChangesBeyondTheCapFallsBackToTheDiffstat(t *testing.T) { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-l", "5")) + if !strings.Contains(repo.lastMessage(), "n.txt |") { + t.Errorf("diffstat summary expected, got: %q", repo.lastMessage()) + } + if strings.Contains(repo.lastMessage(), "n.txt:1:") { + t.Error("no embedded diff lines") + } + if strings.Contains(repo.lastMessage(), "\x1b") { + t.Error("upstream's stat command takes no colour flag") + } +} + +func TestListChangesWithOnlyNewFilesListsThem(t *testing.T) { + repo := newTestRepo(t) + repo.write("seed.txt", "seed\n") + autoCommit(repo.spec()) + repo.write("fresh.txt", "hello\n") + autoCommit(repo.spec("-l", "10")) + if repo.lastMessage() != "New files added: ?? fresh.txt" { + t.Errorf("status is taken before git add, got: %q", repo.lastMessage()) + } +} + +func TestListChangesReportsADeletedFile(t *testing.T) { + repo := newTestRepo(t) + repo.write("doomed.txt", "contents\n") + repo.write("keep.txt", "kept\n") + autoCommit(repo.spec()) + os.Remove(filepath.Join(repo.path, "doomed.txt")) + autoCommit(repo.spec("-l", "10")) + if repo.lastMessage() != "File doomed.txt deleted or moved." { + t.Errorf("got: %q", repo.lastMessage()) + } +} + +// Upstream hands git the raw bytes it read; git transcodes them into the object, +// so assert on the built message, not the stored one. +func TestListChangesEmbedsANonUTF8Diff(t *testing.T) { + repo := newTestRepo(t) + file := filepath.Join(repo.path, "x.txt") + os.WriteFile(file, []byte("alpha\n"), 0o644) + autoCommit(repo.spec()) + os.WriteFile(file, []byte{0x62, 0xE9, 0x74, 0x61, 0x0A}, 0o644) // Latin-1 e-acute + msg := listChangesMessage(repo.spec("-l", "10")) + if !strings.Contains(msg, "x.txt:1: ") { + t.Errorf("a non-UTF-8 byte must not empty the diff, got: %q", msg) + } + if !strings.Contains(msg, "\xe9") { + t.Errorf("the invalid byte survives verbatim, got: %q", msg) + } +} + +// Pinned bug-for-bug: git > 2.39 rejects the empty colour argument as a pathspec, +// so every -L commit degrades to the status summary. + +func TestPlainListChangesDegradesToTheStatusSummary(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "alpha\n") + autoCommit(repo.spec()) + repo.write("a.txt", "beta\n") + if got := autoCommit(repo.spec("-L", "10", "-m", "unused")); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if repo.lastMessage() != "New files added: M a.txt" { + t.Errorf("upstream's degraded -L output, got: %q", repo.lastMessage()) + } +} + +func TestPlainListChangesNeverAppliesTheCap(t *testing.T) { + // The empty diff message always satisfies the length gate, so no -L value + // (small cap or 0 = unlimited) ever embeds a diff or a diffstat. + for _, cap := range []string{"5", "0"} { + repo := newTestRepo(t) + repo.write("n.txt", numberedLines("old", 10)) + autoCommit(repo.spec()) + repo.write("n.txt", numberedLines("new", 10)) + autoCommit(repo.spec("-L", cap)) + if repo.lastMessage() != "New files added: M n.txt" { + t.Errorf("-L %s: upstream's degraded output, got: %q", cap, repo.lastMessage()) + } + } +} + +// -c/-C: the command's stdout is the message, overriding -m, -d and -l/-L. + +func TestCommitCommandOutputBecomesTheMessage(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-c", "echo release notes", "-m", "unused")) + if repo.lastMessage() != "release notes" { + t.Errorf("got %q", repo.lastMessage()) + } +} + +func TestCommitCommandIsWordSplitNotShellInterpreted(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-c", "echo a && echo b")) + if repo.lastMessage() != "a && echo b" { + t.Errorf("got %q", repo.lastMessage()) + } +} + +func TestCommitCommandOverridesListChanges(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "alpha\n") + autoCommit(repo.spec()) + repo.write("a.txt", "beta\n") + autoCommit(repo.spec("-l", "10", "-m", "unused", "-c", "echo from the command")) + if repo.lastMessage() != "from the command" { + t.Errorf("-c is applied last, got %q", repo.lastMessage()) + } +} + +// Exit status is ignored; printing nothing leaves an empty -m, which git refuses. +func TestFailingCommitCommandAbortsTheCommit(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-c", "false", "-m", "unused")) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if repo.commitCount() != 0 { + t.Error("no commit is made with an empty message") + } +} + +func TestPipeChangedFilesFeedsTheCommandStdin(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + repo.write("b.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("a.txt", "v2\n") + repo.write("b.txt", "v2\n") + autoCommit(repo.spec("-c", "cat", "-C")) + if repo.lastMessage() != "a.txt\nb.txt" { + t.Errorf("`git diff --name-only` of the unstaged tree, got %q", repo.lastMessage()) + } + repo.write("a.txt", "v3\n") + if got := autoCommit(repo.spec("-c", "cat")); got.Kind != CommitFailed { + t.Errorf("without -C the command reads /dev/null and prints nothing: %+v", got) + } +} + func TestPushToRemote(t *testing.T) { repo := newTestRepo(t) origin := repo.addOrigin() @@ -510,3 +737,21 @@ func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { t.Error("the conflicted rebase is left in progress") } } + +// Matching macOS, not upstream: upstream runs the -c command on every cycle, +// even with nothing to commit; we short-circuit first. +func TestCommitCommandDoesNotRunOnACleanCycle(t *testing.T) { + repo := newTestRepo(t) + repo.commit("a.txt", "v1\n", "seed") + marker := filepath.Join(t.TempDir(), "ran") + spec, errMsg := parseRepoSpec([]string{"-c", "touch " + marker, repo.path}) + if spec == nil { + t.Fatal(errMsg) + } + if got := autoCommit(spec); got.Kind != Clean { + t.Fatalf("got %v", got) + } + if _, err := os.Stat(marker); err == nil { + t.Error("the -c command ran on a clean cycle") + } +} diff --git a/linux/gitwatch_parity_test.go b/linux/gitwatch_parity_test.go index d7692bb..1098a29 100644 --- a/linux/gitwatch_parity_test.go +++ b/linux/gitwatch_parity_test.go @@ -193,6 +193,121 @@ func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { } } +// These scenarios compare whole commit-message bodies against the oracle. + +func TestParityListChangesEmbedsTheDiff(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "tracked") + repo.write("notes.txt", "hello again\n") + }) + expectParity(t, model, ours) + if !strings.Contains(ours.lastMessage, "notes.txt:1: ") || !strings.Contains(ours.lastMessage, "\x1b[") { + t.Errorf("both sides embed the coloured diff: %q", ours.lastMessage) + } +} + +func TestParityListChangesCutsLongLines(t *testing.T) { + model, ours := twins(t, []string{"-l", "0"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("long.txt", "short\n", "seed") + repo.write("long.txt", strings.Repeat("x", 200)+"\n") + }) + expectParity(t, model, ours) + if strings.Contains(ours.lastMessage, strings.Repeat("x", 150)) { + t.Errorf("both sides cut at 150 characters: %q", ours.lastMessage) + } +} + +func TestParityListChangesFallsBackToTheDiffstat(t *testing.T) { + model, ours := twins(t, []string{"-l", "5"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("n.txt", numberedLines("old", 10), "seed") + repo.write("n.txt", numberedLines("new", 10)) + }) + expectParity(t, model, ours) + if !strings.Contains(ours.lastMessage, "n.txt |") { + t.Errorf("20 diff lines exceed -l 5, so both sides summarise: %q", ours.lastMessage) + } +} + +func TestParityListChangesWithOnlyNewFiles(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("fresh.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "New files added: ?? fresh.txt" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// -L's empty colour argument makes both sides run `git diff -U0 ""`, which +// this git rejects, so both degrade to the status summary. +func TestParityPlainListChangesDegrades(t *testing.T) { + model, ours := twins(t, []string{"-L", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "alpha\n", "seed") + repo.write("a.txt", "beta\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "New files added: M a.txt" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// "&&" is an argument to echo, not a shell operator. +func TestParityCommitCommandIsWordSplit(t *testing.T) { + model, ours := twins(t, []string{"-c", "echo a && echo b"}, false, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "a && echo b" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// The trailing -f is upstream's `C:` bug showing through: -C swallows the next +// token, so feeding it a duplicate -f keeps both sides' effective flags equal. +func TestParityCommitCommandReadsChangedFilesFromStdin(t *testing.T) { + model, ours := twins(t, []string{"-c", "cat", "-C", "-f"}, false, "", + func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "v1\n", "seed") + repo.commit("b.txt", "v1\n", "seed b") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "v2\n") + }) + expectParity(t, model, ours) + if ours.lastMessage != "a.txt\nb.txt" { + t.Errorf("got %q", ours.lastMessage) + } +} + +// Exit status is ignored; printing nothing leaves an empty -m on both sides. +func TestParityFailingCommitCommandCommitsNothing(t *testing.T) { + model, ours := twins(t, []string{"-c", "false"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 1 || ours.pendingChanges == 0 { + t.Errorf("the change stays uncommitted on both sides: %v", ours) + } +} + +// -v turns on upstream's tracing and changes nothing about the repo. +func TestParityVerboseChangesNothing(t *testing.T) { + model, ours := twins(t, []string{"-v", "-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.lastMessage != "cycle" { + t.Errorf("got %v", ours) + } +} + func TestParityPushToRemote(t *testing.T) { model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", func(repo *testRepo, _ *bareRemote) { @@ -314,3 +429,45 @@ func TestParityRebaseConflictLeftInProgress(t *testing.T) { t.Error("both sides stop mid-rebase; -M is the only guard") } } + +// Pinned upstream bug: the unanchored header regexes run before the content one, +// so a changed line whose text contains "--- x" is eaten and corrupts the path. +func TestParityListChangesEatsContentLinesResemblingHeaders(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "alpha\n", "seed") + repo.write("a.txt", "alpha\n--- section two\nbeta\n") + }) + expectParity(t, model, ours) + if strings.Contains(ours.lastMessage, "section") || !strings.Contains(ours.lastMessage, "beta") { + t.Errorf("got %q", ours.lastMessage) + } +} + +// Pinned upstream bug: the 150-char cut counts colour escapes and can land inside +// one, leaving a dangling ESC in the commit message. +func TestParityListChangesCutCanSplitAnEscape(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "short\n", "seed") + // 135 content chars put char 150 inside this git's trailing \x1b[m. + repo.write("a.txt", strings.Repeat("x", 135)+"\n") + }) + expectParity(t, model, ours) + if !strings.HasSuffix(ours.lastMessage, "\x1b") && !strings.HasSuffix(ours.lastMessage, "\x1b[") { + t.Errorf("expected a dangling escape, got %q", ours.lastMessage) + } +} + +// Pinned upstream quirk: the -l diff is repo-wide even for a file target, so the +// message can describe changes the commit does not include. +func TestParityListChangesOnAFileTargetEmbedsTheWholeRepoDiff(t *testing.T) { + model, ours := twins(t, []string{"-l", "10"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { + repo.commit("a.txt", "v1\n", "seed") + repo.commit("b.txt", "v1\n", "seed b") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "v2\n") + }) + expectParity(t, model, ours) + if !strings.Contains(ours.lastMessage, "b.txt:") || ours.pendingChanges == 0 { + t.Errorf("the message names b.txt yet b.txt stays uncommitted: %v", ours) + } +} From 49961a48186cdaaf9d172528c7e766ad0737db7f Mon Sep 17 00:00:00 2001 From: Will Howard Date: Thu, 30 Jul 2026 11:36:49 +0100 Subject: [PATCH 5/6] Linux support: port in fix to relative paths in rm, pause (#13) --- linux/cli.go | 9 ++++---- linux/cli_test.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index 8b0da31..32e3791 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -646,7 +646,7 @@ func configAppend(line string) { } func configMatches(spec *RepoSpec, nameOrPath string) bool { - return spec.Path == expandTilde(nameOrPath) || spec.Name() == nameOrPath || spec.Path == nameOrPath + return spec.Name() == nameOrPath || spec.Path == nameOrPath || spec.Path == normalizePath(nameOrPath) } func configRemove(nameOrPath string) int { @@ -673,10 +673,9 @@ func configRemove(nameOrPath string) int { return removed } -// Flip the --paused token on config lines matching `nameOrPath` (by full -// path or repo name, like remove). Pause lives in the config, not daemon -// state, so it survives daemon and machine restarts. Returns the number -// of lines changed. +// Flip the --paused token on config lines matching `nameOrPath` (as remove +// matches). Pause lives in the config, not daemon state, so it survives +// daemon and machine restarts. Returns the number of lines changed. func configSetPaused(nameOrPath string, paused bool) int { raw, err := os.ReadFile(configPath()) if err != nil { diff --git a/linux/cli_test.go b/linux/cli_test.go index 898ee96..a18c054 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -123,6 +123,45 @@ func TestRmMatchesByFullPath(t *testing.T) { } } +func TestRmMatchesTheRepoItRunsIn(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + t.Chdir(repo.path) + if cliRun([]string{"rm", "."}) != 0 { + t.Error("rm . failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmMatchesARelativePath(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + t.Chdir(filepath.Dir(repo.path)) + if cliRun([]string{"rm", "./" + repo.spec().Name()}) != 0 { + t.Error("rm by relative path failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmMatchesByNameFromAnUnrelatedDirectory(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + t.Chdir(t.TempDir()) + if cliRun([]string{"rm", repo.spec().Name()}) != 0 { + t.Error("rm by name failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + func TestRmUnknownFailsAndLeavesConfigAlone(t *testing.T) { withTemporaryConfig(t) repo := newTestRepo(t) @@ -171,6 +210,25 @@ func TestPauseByFullPath(t *testing.T) { } } +func TestPauseAndResumeTakeARelativeTarget(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + t.Chdir(repo.path) + if cliRun([]string{"pause", "."}) != 0 { + t.Fatal("pause . failed") + } + if !configSpecs()[0].Paused { + t.Error("spec should be paused") + } + if cliRun([]string{"resume", "."}) != 0 { + t.Fatal("resume . failed") + } + if configSpecs()[0].Paused { + t.Error("spec should be resumed") + } +} + func TestPausingTwiceFailsPolitely(t *testing.T) { withTemporaryConfig(t) repo := newTestRepo(t) From f30d98e046bfbcf749d7d732825806065aeaf467 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Thu, 30 Jul 2026 12:02:27 +0100 Subject: [PATCH 6/6] Linux support: Add an installer, plus some copy changes (#14) * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 36 ++++++++ install.sh | 148 +++++++++++++++++++++++++++++++++ linux/cli.go | 152 +++++++++++++++++----------------- linux/cli_test.go | 59 ++++++------- linux/daemon.go | 10 ++- linux/daemon_test.go | 67 +++++---------- 6 files changed, 319 insertions(+), 153 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 32e3791..7860a70 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 } @@ -953,12 +951,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") } @@ -967,8 +965,8 @@ func systemctlPresent() bool { return err == nil } -func unitInstalled() bool { - _, err := os.Stat(unitPath()) +func daemonServiceInstalled() bool { + _, err := os.Stat(daemonServicePath()) return err == nil } @@ -976,27 +974,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] @@ -1007,11 +1026,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 "" @@ -1034,13 +1053,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) @@ -1051,12 +1070,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 } @@ -1066,12 +1085,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 @@ -1082,7 +1101,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 } @@ -1091,7 +1110,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) @@ -1099,29 +1118,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 @@ -1134,9 +1136,6 @@ const ( ) func autostartActionFor(c autostartConditions) autostartAction { - if !c.installedBinary { - return autostartLeaveAlone - } if c.recorded && !c.wantsOn { return autostartLeaveAlone // `autostart off` is never overridden } @@ -1149,7 +1148,7 @@ func autostartActionFor(c autostartConditions) autostartAction { if !c.recorded { return autostartEnableFirstRun } - if c.unitEnabled { + if c.daemonServiceEnabled && !c.serviceStale { return autostartLeaveAlone } return autostartReinstate @@ -1157,7 +1156,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 { @@ -1167,22 +1166,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 { @@ -1197,8 +1195,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 a18c054..618732e 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -360,37 +360,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 { @@ -421,19 +422,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) } }