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/.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/.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/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/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/Makefile b/linux/Makefile new file mode 100644 index 0000000..79f1a5e --- /dev/null +++ b/linux/Makefile @@ -0,0 +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 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 + +# 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 . + +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) diff --git a/linux/cli.go b/linux/cli.go new file mode 100644 index 0000000..7860a70 --- /dev/null +++ b/linux/cli.go @@ -0,0 +1,1202 @@ +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 show everything being watched, along with any errors + start, stop start or stop the daemon + autostart [on|off|status] + 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 + 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". + -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. + -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] +// [-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) + 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 { + 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 := "" + if spec.Remote != "" { + branch := spec.Branch + if branch == "" { + branch = currentBranch(spec.WorkDir(), spec.GitDir) + } + pushNote = "→ " + spec.Remote + "/" + branch + ", " + } + 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 +} + +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 one with: ssh-add") + } + } else { + fmt.Println(" SSH_AUTH_SOCK (unset) ⚠ SSH pushes will fail from the daemon") + } + 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 daemonServiceInstalled() && 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 daemonServiceInstalled() && systemctlPresent() { + logs = "journalctl --user -u gitwatchd" + } + warn("daemon did not start; check " + logs) + return 1 + } + fmt.Println("✓ daemon started") + return 0 +} + +func cliStop() int { + if !isDaemonRunning() { + fmt.Println("daemon not running") + return 0 + } + if daemonServiceInstalled() && systemctlPresent() && daemonServiceActive() { + 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 daemonServiceInstalled() && 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.Name() == nameOrPath || spec.Path == nameOrPath || spec.Path == normalizePath(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` (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 { + 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)", + ListChanges: -1, + ListChangesColor: true, + } + 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 "-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 { + 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 "-v": + // verbose: accepted, no-op (gitwatch's -v turns on bash tracing) + 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 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 daemonServicePath() string { + return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") +} + +func systemctlPresent() bool { + _, err := exec.LookPath("systemctl") + return err == nil +} + +func daemonServiceInstalled() bool { + _, err := os.Stat(daemonServicePath()) + return err == nil +} + +func systemctlUser(args ...string) (int, string) { + return runCommand("systemctl", append([]string{"--user"}, args...), "") +} + +func daemonServiceActive() bool { + _, out := systemctlUser("is-active", "gitwatchd") + return out == "active" +} + +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 writeDaemonService() string { + exe, err := os.Executable() + if err != nil { + return "cannot resolve the gitwatchd binary path: " + err.Error() + } + exe, _ = filepath.EvalSymlinks(exe) + service := 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(daemonServicePath()), 0o755); err != nil { + return "cannot create " + filepath.Dir(daemonServicePath()) + ": " + err.Error() + } + if err := os.WriteFile(daemonServicePath(), []byte(service), 0o644); err != nil { + return "cannot write " + daemonServicePath() + ": " + 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 := writeDaemonService(); msg != "" { + warn(msg) + return 1 + } + // A directly spawned daemon holds the single-instance lock and would + // 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) + } + } + 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 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 service enabled)") + 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 !daemonServiceInstalled() { + fmt.Println("autostart: already off") + return 0 + } + systemctlUser("disable", "--now", "gitwatchd") + os.Remove(daemonServicePath()) + 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 !daemonServiceInstalled() { + fmt.Println("autostart: off") + return 0 + } + _, enabled := systemctlUser("is-enabled", "gitwatchd") + state := "on" + if enabled != "enabled" { + state = "installed but " + enabled + } + if daemonServiceActive() { + fmt.Printf("autostart: %s (daemon running)\n", state) + } else { + fmt.Printf("autostart: %s (daemon not running)\n", state) + } + return 0 +} + +type autostartConditions struct { + 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 + +const ( + autostartLeaveAlone autostartAction = iota + autostartEnableFirstRun + autostartReinstate + autostartReportUnavailable +) + +func autostartActionFor(c autostartConditions) autostartAction { + 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.daemonServiceEnabled && !c.serviceStale { + 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 := writeDaemonService(); 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 "" +} + +// 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 { + wantsOn, recorded := launchAtLogin() + conditions := autostartConditions{ + recorded: recorded, + wantsOn: wantsOn, + systemdPresent: systemctlPresent(), + } + if conditions.systemdPresent { + conditions.daemonServiceEnabled = daemonServiceEnabled() + if conditions.daemonServiceEnabled { + conditions.serviceStale = !daemonServicePointsAtThisBinary() + } + } + 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 service enabled on first run; " + + "turn it off with `gitwatchd autostart off`)" + } + 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 new file mode 100644 index 0000000..618732e --- /dev/null +++ b/linux/cli_test.go @@ -0,0 +1,795 @@ +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 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) + 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 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) + 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, the daemon service's state and whether systemd is here. + +func TestAutostartDecisionTable(t *testing.T) { + cases := []struct { + what string + conditions autostartConditions + want autostartAction + }{ + {"the first run turns autostart on", + autostartConditions{systemdPresent: true}, autostartEnableFirstRun}, + {"a wish that is already satisfied needs nothing", + 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{systemdPresent: true, recorded: true}, + autostartLeaveAlone}, + {"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{recorded: true, wantsOn: true}, + autostartLeaveAlone}, + {"an opt-out without systemd stays quiet", + autostartConditions{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 TestDaemonServiceFollowsTheRunningBinary(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if daemonServicePointsAtThisBinary() { + t.Error("no service file at all cannot point at this binary") + } + 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") + } + 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") + } +} + +// 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") + } + 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) { + 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 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") { + 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", + "-l": "10", "-L": "10", "-c": "echo hi", +} + +var contractBoolFlags = []string{"-R", "-M", "-f", "-C", "--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..0af1815 --- /dev/null +++ b/linux/daemon.go @@ -0,0 +1,617 @@ +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()) + 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() + } + }() + + // 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 + 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..dc9ffde --- /dev/null +++ b/linux/daemon_test.go @@ -0,0 +1,546 @@ +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) }) + // 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 { + 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 service file 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 TestFirstDaemonRunOnboardsAutostart(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) + } + 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 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 := runCLI(env, "stop"); code != 0 { + t.Fatalf("stop: code=%d out=%s", code, out) + } + if code, out := runCLI(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 := runCLI(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..f5faa33 --- /dev/null +++ b/linux/git_driver.go @@ -0,0 +1,365 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "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) +} + +// $() 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" +} + +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) +} + +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) { + return Outcome{Kind: SkippedMerge} + } + if pendingCount(dir, spec.GitDir) == 0 { + return Outcome{Kind: Clean} + } + + // 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. + 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..56a23d9 --- /dev/null +++ b/linux/git_driver_test.go @@ -0,0 +1,757 @@ +package main + +import ( + "fmt" + "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 +} + +// %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")) + 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=%B", "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 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() + 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") + } +} + +// 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 new file mode 100644 index 0000000..1098a29 --- /dev/null +++ b/linux/gitwatch_parity_test.go @@ -0,0 +1,473 @@ +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) + } +} + +// 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) { + 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") + } +} + +// 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) + } +} 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/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/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 99% rename from Tests/ParityTests.swift rename to macos/Tests/ParityTests.swift index b0016ac..8e8f6ad 100644 --- a/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/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 diff --git a/Tests/Reference/README.md b/oracle/README.md similarity index 70% rename from Tests/Reference/README.md rename to oracle/README.md index 5686691..c6c1e13 100644 --- a/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/Tests/Reference/gitwatch.sh b/oracle/gitwatch.sh similarity index 100% rename from Tests/Reference/gitwatch.sh rename to oracle/gitwatch.sh