Linux daemon: support everything except -l/-L and -c/-C - #6
Merged
Conversation
The engine is the same gitwatch cycle the macOS side runs, so the two implementations leave a repo in the same state: stage, commit with the %d date spliced into the first token only, then pull --rebase (-R) and push (-r/-b), fire-and-forget as upstream. A differential parity suite runs each scenario through the vendored gitwatch.sh and through us on identically-built worlds and compares the git state afterwards; those tests are the spec. The watcher is recursive inotify with gitwatch's event set, a -s debounce, .git-churn filtering so our own commits don't retrigger, and -x exclusion. New subdirectories start being watched as they appear, and inotify watch exhaustion surfaces as repo state naming the sysctl to raise rather than killing the daemon. The daemon is one flock-guarded process that live-reloads ~/.gitwatchd, publishes per-repo error state to a JSON file for `status`, and retries a failed push on a 30s-to-5m backoff. The retry only ever re-runs the push stage of a cycle, never the commit, so the observability layer stays additive. The CLI mirrors the mac command set (add/rm/pause/resume/status/start/ stop/config/autostart/doctor) and takes gitwatch's flags verbatim. Autostart is a systemd user unit; where systemd is absent it says so and points at `gitwatchd start`, and never edits a shell profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Will-Howard
force-pushed
the
linux-daemon
branch
from
July 28, 2026 14:59
6f68ca2 to
a2af805
Compare
install.sh puts the host-built binary on PATH for the current user. `make install` and `make uninstall` route through it, so the root Makefile's forwarding works on Linux the way it already does on macOS. The build targets keep the shape the skeleton set: all/build/test/ clean, static to build/gitwatchd, test = vet + test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Will-Howard
force-pushed
the
linux-daemon
branch
from
July 28, 2026 15:11
a2af805 to
6689afd
Compare
The Go side had grown to ten source files and nine test files for what is really three concerns, so the package now says so directly: cli.go is everything the foreground command does (dispatch, the ~/.gitwatchd config file, repo-spec parsing, status formatting, systemd autostart), daemon.go is everything the background process does (the daemon loop and pidfile lock, the recursive inotify watcher, the JSON state file), and git_driver.go is the git subprocess layer and the gitwatch commit and push cycle. formattedDate moves in with the driver: it spawns date(1), so it belongs beside the other subprocess calls rather than with the flag parser. The tests follow the same three-way split. Nothing moved between concerns and no function, type or test changed: this is placement only, so the parity suite is byte-identical and the same 92 tests run before and after. The comment pass that comes with it keeps every note that encodes upstream gitwatch behaviour, the macOS parity constraints and the non-obvious invariants, and drops the narration and the restatements of the next line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The help text now sits at the top of cli.go, where a reader meets the command surface before the code that implements it, and the config template is one raw string again: it described 'gitwatchd help' by concatenating quoted backticks into a raw literal, which was harder to read than the file it writes. Renames and comment work: the config lookup key is nameOrPath rather than needle, since name-or-path is exactly what the user may pass; OutcomeKind becomes CommitOutcome, matching the macOS enum of the same name; autoCommit's parity notes become four numbered steps, keeping every upstream fact but making the order of the cycle the first thing you see. The -e case says plainly that the flag is accepted for gitwatch compatibility and ignored. truncated, ago and span move to utils.go. They are pure formatting with no gitwatchd vocabulary in them, and they were the only such helpers in cli.go. The spawnsDaemon test seam is gone: GITWATCHD_NO_SPAWN already exists and does the same job, so the in-process CLI tests set that instead. One seam is enough. Those tests still need it, because os.Executable() under go test is the test binary, but that leaves the real spawn path untested, so TestAddSpawnsTheDaemon drives the built binary with an isolated HOME and without GITWATCHD_NO_SPAWN: add brings the daemon up, stop takes it down, and nothing survives the test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The twelve TestParity cases measure us against the vendored upstream script rather than against our own expectations, which makes them the oracle for the prime directive and worth finding at a glance. They now live in their own file along with the harness only they use: the repoState fingerprint, the gitwatch.sh runner and its watcher stub, the twin-world builder and the plain-git scenario builders. The shared fixtures (testRepo, bareRemote) stay in git_driver_test.go, where the engine tests also use them. A pure move: no test, helper or assertion changed, and the same 93 tests run before and after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The vendored upstream script was under macos/Tests/Reference because the macOS suite was the only thing measuring itself against it. Both implementations now do, so the oracle is not a macOS test fixture: it belongs beside macos/ and linux/, named for what it is. The two parity suites pick it up from the new path, and its README names them both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shared declarations open the file now: where the daemon's runtime files live (stateDir and the three paths off it), the inotify event mask gitwatch uses, RepoStatus, and the state file itself. That file's mutex was a loose global sitting between three free functions, so it is a stateFile type with the mutex as its one field and statuses, setStatus and keepOnly as its methods. The daemon hands daemonState.setStatus to each watcher and `gitwatchd status` calls daemonState.statuses(). runDaemon held a fifty line reload closure over two maps. The maps and the logger are a daemon struct, so runDaemon reads as lock, reload, watch the config, wait for a signal, stop, and reloadConfig is a top-level method you can read on its own. inotify parsing existed twice: the watcher decoded events through syscall.InotifyEvent while the config watcher hand-rolled a second parser out of inotifyNamesContain and uint32frombytes. Both go through readInotifyEvents now, which reads one fd until it closes and hands each event's descriptor, mask and name to a callback; the config watcher compares that name to the config file's basename. The hand-rolled pair is gone. Names: fd is inotifyFD, wds is dirByWatchDescriptor, mu is watchMu, watchErr is watchFailure, events and flush are fileChanges and flushRequests, stop and done are stopping and stopped, onState is onStatus. addWatchTracked is watchDirectory, addRecursive is watchTree, addWatchError is recordWatchFailure, run is runCycles, publish is publishStatus, handleEvent is handleInotifyEvent. drain and watchError were single-caller one-liners and are inlined. The non-blocking sends that coalesce wakeups were the same four lines in three places; they are one queueWakeup helper that says why a full channel is success rather than a reason to block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stop waited five seconds for the daemon to go and then handed back a pkill hint, which leaves the user with a running daemon, a command that reported failure and homework. It now kills the process outright and says which it took: "daemon stopped", or "daemon stopped (it ignored the stop signal, so it was killed)". Only a pidfile naming no process, or a process that survives SIGKILL, is still an error. The wait is a waitForDaemonExit helper, shared with the handover in `autostart on`, which polled for the same thing in the same way. TestStopKillsADaemonThatIgnoresSIGTERM re-executes the test binary in a mode that takes the pidfile lock and ignores SIGTERM, then runs the real stop against it and asserts the child died of SIGKILL. With the old give-up path in place the test fails on stop's exit code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The macOS app reconciles launch-at-login on every launch of an installed copy; the Linux daemon asked the user to run `autostart on` themselves. Port the same contract to daemon startup: the first start of a binary that lives where install.sh puts it enables the systemd user unit and records the wish, a wish already recorded as off is never overridden, and a unit that went missing (a reinstall) is quietly re-enabled. The decision is a pure function over the recorded wish, where the binary lives and whether systemd is here, so the whole table is testable on hosts without systemd. Without systemd the reconcile degrades as the explicit command does: no shell profile is ever written, the log points at `gitwatchd start`, and it says so once rather than on every start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The autostart wish had a file of its own beside the daemon's error state. Both now live in state.json, pretty-printed with settings as top-level keys and repo state under "repos": the shape macOS will adopt when it leaves its SQLite key-value store, and one a person can read and diff. The file has two writers now (the daemon publishes repo state, the CLI records settings), so a read-modify-write takes an exclusive flock for its duration. The lock is a file of its own: state.json is replaced by rename, so a lock taken on it would be left holding an unlinked inode while the next writer locked its replacement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One installer shape on both platforms: `make install` now does the work inline, mac-style. It picks the first writable of /usr/local/bin, ~/.local/bin, ~/bin (DEST still overrides), warns when that dir is off PATH, stops any running daemon before replacing the binary, and starts the installed copy, which is what turns autostart on for its first run. Uninstall no longer trusts PATH: it calls `autostart off` through the binary at each known absolute path, removes the systemd unit itself, stops the daemon via its pidfile with a SIGKILL fallback, and reports only what it actually found. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
After 30 consecutive push failures (about two hours of network outage) the doubling overflowed time.Duration, the delay went negative, and the retry timer fired immediately: the daemon hammered git push in a tight loop for the rest of the outage. Compare in float64 before converting, so a day-long outage retries calmly every five minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a linux version of the command line application, aiming to be like-for-like with the mac version. Key points for review: