From 2183d068df2aac42e06a53a159083111ca80454d Mon Sep 17 00:00:00 2001 From: Will Howard Date: Tue, 28 Jul 2026 14:28:18 +0000 Subject: [PATCH 01/19] Implement the Linux daemon: inotify watcher, engine, CLI 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 --- linux/cli.go | 461 +++++++++++++++++++++++++++++++++++++ linux/cli_test.go | 234 +++++++++++++++++++ linux/config.go | 253 ++++++++++++++++++++ linux/config_test.go | 77 +++++++ linux/daemon.go | 205 +++++++++++++++++ linux/daemon_test.go | 163 +++++++++++++ linux/flagcontract_test.go | 163 +++++++++++++ linux/formatting_test.go | 133 +++++++++++ linux/git.go | 254 ++++++++++++++++++++ linux/gitengine_test.go | 371 +++++++++++++++++++++++++++++ linux/helpers_test.go | 198 ++++++++++++++++ linux/main.go | 8 +- linux/parity_test.go | 316 +++++++++++++++++++++++++ linux/repospec.go | 188 +++++++++++++++ linux/state.go | 84 +++++++ linux/statusformat.go | 102 ++++++++ linux/systemd.go | 134 +++++++++++ linux/watcher.go | 313 +++++++++++++++++++++++++ linux/watcher_test.go | 126 ++++++++++ 19 files changed, 3777 insertions(+), 6 deletions(-) create mode 100644 linux/cli.go create mode 100644 linux/cli_test.go create mode 100644 linux/config.go create mode 100644 linux/config_test.go create mode 100644 linux/daemon.go create mode 100644 linux/daemon_test.go create mode 100644 linux/flagcontract_test.go create mode 100644 linux/formatting_test.go create mode 100644 linux/git.go create mode 100644 linux/gitengine_test.go create mode 100644 linux/helpers_test.go create mode 100644 linux/parity_test.go create mode 100644 linux/repospec.go create mode 100644 linux/state.go create mode 100644 linux/statusformat.go create mode 100644 linux/systemd.go create mode 100644 linux/watcher.go create mode 100644 linux/watcher_test.go diff --git a/linux/cli.go b/linux/cli.go new file mode 100644 index 0000000..9547e45 --- /dev/null +++ b/linux/cli.go @@ -0,0 +1,461 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// The `gitwatchd` command-line client. Writes the shared config file (so it +// works even when the daemon is down) and relies on the running daemon's live +// reload. `add` accepts gitwatch's own flags verbatim, so gitwatch users need +// no relearning. + +// 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" + +// Tests set this false so CLI calls don't spawn the real daemon. +var spawnsDaemon = true + +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: + // Bare form: `gitwatchd [flags] `: implicit add. + return cliAdd(args) + } +} + +func cliAdd(args []string) int { + spec, errMsg := parseRepoSpec(args) + if spec == nil { + if errMsg == "" { + errMsg = "could not parse arguments" + } + warn(errMsg) + return 1 + } + if _, err := os.Stat(spec.Path); err != nil { + warn("path does not exist: " + spec.Path) + return 1 + } + if !isRepo(spec.WorkDir(), spec.GitDir) { + warn("not inside a git repo: " + spec.Path + "\n run: git init " + spec.WorkDir()) + return 1 + } + for _, existing := range configSpecs() { + if existing.Path == spec.Path { + warn(fmt.Sprintf("already watching %s (%s)", spec.Name(), spec.Path)) + return 1 + } + } + + // Persist what the user typed (gitwatch-style line), with the target + // resolved to an absolute path: the daemon reads this file from a + // different working directory, so `gitwatchd .` must not store ".". + quoted := make([]string, len(args)) + for i, a := range args { + if i == spec.targetIndex { + a = spec.Path + } + quoted[i] = quoteIfNeeded(a) + } + configAppend(strings.Join(quoted, " ")) + ensureDaemonRunning() + + pushNote := "local only" + if spec.Remote != "" { + branch := spec.Branch + if branch == "" { + branch = currentBranch(spec.WorkDir(), spec.GitDir) + } + pushNote = "→ " + spec.Remote + "/" + branch + } + fmt.Printf("✓ watching %s (%s) %s, settle %ds\n", spec.Name(), spec.Path, pushNote, int(spec.Settle)) + fmt.Println(" gitwatchd status to see everything watched") + return 0 +} + +func cliRemove(args []string) int { + if len(args) == 0 { + warn("usage: gitwatchd rm ") + return 1 + } + needle := args[0] + n := configRemove(needle) + if n == 0 { + warn("no watched repo matches " + needle) + return 1 + } + ensureDaemonRunning() + entries := "entries" + if n == 1 { + entries = "entry" + } + fmt.Printf("✓ stopped watching %s (%d %s removed)\n", needle, 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 + } + needle := args[0] + n := configSetPaused(needle, paused) + if n == 0 { + known := false + for _, s := range configSpecs() { + if configMatches(s, needle) { + known = true + } + } + if known { + state := "watching" + if paused { + state = "paused" + } + warn(needle + " is already " + state) + } else { + warn("no watched repo matches " + needle) + } + return 1 + } + ensureDaemonRunning() + if paused { + fmt.Printf("⏸ paused %s (resume with: gitwatchd resume %s)\n", needle, needle) + } else { + fmt.Printf("✓ resumed %s; catching up on anything that changed meanwhile\n", needle) + } + return 0 +} + +// The status rows, same strings as the macOS menu, plus the daemon's +// published error state when it is running. +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 = stateErrors() + } + repos := "repos" + if len(specs) == 1 { + repos = "repo" + } + fmt.Printf("Watching %d %s\n", len(specs), repos) + fmt.Println("") + now := time.Now() + for _, s := range specs { + branch := currentBranch(s.WorkDir(), s.GitDir) + pending := pendingCount(s.WorkDir(), s.GitDir) + errStatus, hasErr := daemonErrors[s.Path] + label := "" + if hasErr { + label = errStatus.ErrorLabel + } + fmt.Println(rowTitle(s.Name(), branch, s.Paused, pending, label)) + fmt.Println(" " + lastCommitSummary(s.WorkDir(), s.GitDir)) + if hasErr { + fmt.Println(" " + errorHeadline(errStatus.ErrorLabel, errStatus.Attempts)) + if errStatus.Detail != "" { + fmt.Println(" " + truncated(errStatus.Detail, 60)) + } + var next *time.Time + if errStatus.NextRetry != nil { + t := time.Unix(*errStatus.NextRetry, 0) + next = &t + } + fmt.Println(" " + retryLine(time.Unix(errStatus.LastAttempt, 0), next, now)) + } + } + for _, e := range errors { + fmt.Println(configErrorRow(e.Label, e.Reason)) + fmt.Println(" " + e.Detail) + } + return 0 +} + +// Undocumented diagnostic: the environment the daemon will use for git. +func cliDoctor() int { + fmt.Println("What the daemon will use for git:") + gitPath, err := exec.LookPath("git") + if err != nil { + fmt.Println(" git binary not found in PATH") + return 1 + } + _, gitVersion := runCommand(gitPath, []string{"--version"}, "") + fmt.Println(" git binary " + gitPath) + fmt.Println(" git --version " + gitVersion) + path := os.Getenv("PATH") + if path == "" { + path = "(unset)" + } + fmt.Println(" PATH " + path) + if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { + code, out := runCommand("ssh-add", []string{"-l"}, "") + n := 0 + if code == 0 && out != "" { + n = len(strings.Split(out, "\n")) + } + keys := "keys" + if n == 1 { + keys = "key" + } + fmt.Printf(" SSH_AUTH_SOCK present · %d %s in agent\n", n, keys) + if n == 0 { + fmt.Println(" ⚠ no keys loaded: SSH pushes may fail. Add: ssh-add ~/.ssh/id_ed25519") + } + } else { + fmt.Println(" SSH_AUTH_SOCK (unset) ⚠ SSH pushes will fail from the daemon unless keys are unencrypted") + } + return 0 +} + +func cliConfig(args []string) int { + if len(args) == 0 || args[0] == "path" { + fmt.Println(configPath()) + return 0 + } + if args[0] == "edit" { + return cliEditConfig() + } + warn("usage: gitwatchd config [path|edit]") + return 1 +} + +// Open the config in the editor `git commit` would use: `git var GIT_EDITOR` +// resolves $GIT_EDITOR, core.editor, $VISUAL, $EDITOR, then vi. Execs the +// editor in place of the CLI so full-screen editors keep the terminal; the +// daemon live-reloads off the file change, so there is nothing to do after. +func cliEditConfig() int { + configEnsureExists() + cwd, _ := os.Getwd() + code, out := gitRun([]string{"var", "GIT_EDITOR"}, cwd, "") + editor := "vi" + if code == 0 && out != "" { + editor = out + } + // The editor value can be a command with flags (e.g. "code --wait"), + // so hand it to the shell; the path rides in as $1, safely quoted. + argv := []string{"sh", "-c", editor + " \"$1\"", "gitwatchd-edit", configPath()} + err := syscall.Exec("/bin/sh", argv, os.Environ()) + warn(fmt.Sprintf("couldn't launch editor '%s': %v", editor, err)) + return 1 +} + +func cliAutostart(args []string) int { + if len(args) == 0 { + return autostartStatus() + } + switch args[0] { + case "on": + return autostartOn() + case "off": + return autostartOff() + case "status": + return autostartStatus() + default: + warn("usage: gitwatchd autostart [on|off|status]") + return 1 + } +} + +func cliStart() int { + if isDaemonRunning() { + fmt.Println("daemon already running") + return 0 + } + if unitInstalled() && systemctlPresent() { + if code, out := systemctlUser("start", "gitwatchd"); code != 0 { + warn("systemctl --user start gitwatchd failed: " + out) + return 1 + } + } else if err := spawnDaemon(); err != nil { + warn("could not start the daemon: " + err.Error()) + return 1 + } + for i := 0; i < 20 && !isDaemonRunning(); i++ { + time.Sleep(100 * time.Millisecond) + } + if !isDaemonRunning() { + warn("daemon did not come up; check " + daemonLogHint()) + return 1 + } + fmt.Println("✓ daemon started") + return 0 +} + +func cliStop() int { + if !isDaemonRunning() { + fmt.Println("daemon not running") + return 0 + } + if unitInstalled() && systemctlPresent() && unitActive() { + systemctlUser("stop", "gitwatchd") + } else if pid := daemonPid(); pid > 0 { + syscall.Kill(pid, syscall.SIGTERM) + } + // Wait for the exit so `gitwatchd stop && gitwatchd start` doesn't race + // the old process. + for i := 0; i < 50 && isDaemonRunning(); i++ { + time.Sleep(100 * time.Millisecond) + } + if isDaemonRunning() { + warn("daemon did not exit; force with: pkill -x gitwatchd") + return 1 + } + fmt.Println("✓ daemon stopped") + return 0 +} + +// Detached spawn for systems without systemd: its own session (setsid), output +// to a log file so the terminal can close. +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() +} + +func daemonLogHint() string { + if unitInstalled() && systemctlPresent() { + return "journalctl --user -u gitwatchd" + } + return logfilePath() +} + +// 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 !spawnsDaemon || os.Getenv("GITWATCHD_NO_SPAWN") != "" || isDaemonRunning() { + return + } + if unitInstalled() && systemctlPresent() { + systemctlUser("start", "gitwatchd") + return + } + spawnDaemon() +} + +func warn(msg string) { + fmt.Fprintln(os.Stderr, "✗ "+msg) +} + +const usageText = `gitwatchd - daemon that watches git repos and auto-commits changes + +USAGE + gitwatchd [flags] watch a repo + gitwatchd [args] + +EXAMPLES + gitwatchd . watch the current repo, commit locally + gitwatchd -r origin . also push every commit to origin + gitwatchd -r origin -b main -R . two-way sync: fetch commits made on + other machines and rebase yours on top + before each push to origin/main + gitwatchd status see everything being watched + gitwatchd rm blog stop watching, by name or path + +COMMANDS + add [flags] watch a repo (bare ` + "`gitwatchd [flags] `" + ` works too) + rm stop watching a repo + pause stop watching temporarily; the repo stays listed + resume start watching again and commit what piled up + status everything being watched, in the terminal + start, stop start or stop the daemon + autostart [on|off|status] + run the daemon at boot (installs a systemd user unit) + config [path|edit] print the config file path, or open it in your + editor (the one ` + "`git commit`" + ` uses) + help show this help + version print the version + +FLAGS (for add) + -s Wait after the last change before committing, so a + batch of writes lands as one commit. Default: 2. + -r Push to after every commit. Default: no push. + -b Branch to push to. Without it, a plain ` + "`git push `" + ` + decides. Only meaningful together with -r. + -R Before each push, pull commits made elsewhere and rebase + yours on top (` + "`git pull --rebase `" + `). Use with -r + when more than one machine pushes to the same branch. + -m Commit message; %d becomes the timestamp. + Default: "gitwatchd auto-commit (%d)". + -d Format string for that timestamp (see ` + "`man date`" + `). + Default: "+%Y-%m-%d %H:%M:%S". + -x Skip changes whose path matches this regular + expression (e.g. '\.log$' or 'build/'). + -M Skip committing while the repo has a merge in progress. + -f Commit anything already pending as soon as watching + starts (daemon launch, or when the repo is added). + -g Location of the .git directory, if elsewhere (--git-dir). + --paused Keep the repo in the config but don't watch it. This is + what ` + "`gitwatchd pause`" + ` sets. + +The daemon runs in the background and watches every repo listed in ~/.gitwatchd +` diff --git a/linux/cli_test.go b/linux/cli_test.go new file mode 100644 index 0000000..969d4c5 --- /dev/null +++ b/linux/cli_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 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() + spawnsDaemon = false + t.Cleanup(func() { spawnsDaemon = true }) + dir := t.TempDir() + t.Setenv("GITWATCHD_CONFIG", filepath.Join(dir, "gitwatchd")) + t.Setenv("GITWATCHD_STATE_DIR", filepath.Join(dir, "state")) +} + +func TestAddRefusesAMissingPath(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"add", filepath.Join(t.TempDir(), "no-such-dir")}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be written to the config") + } +} + +func TestAddRefusesANonRepo(t *testing.T) { + withTemporaryConfig(t) + dir := filepath.Join(t.TempDir(), "plain-folder") + os.MkdirAll(dir, 0o755) + if cliRun([]string{"add", dir}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be written to the config") + } +} + +func TestAddPersistsExactlyWhatWasTyped(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + if cliRun([]string{"add", "-s", "5", "-m", "two words", repo.path}) != 0 { + t.Fatal("add failed") + } + lines := configRawLines() + want := `-s 5 -m "two words" ` + repo.path + if len(lines) != 1 || lines[0] != want { + t.Errorf("got %v, want [%q]", lines, want) + } + spec := configSpecs()[0] + if spec.Settle != 5 || spec.Message != "two words" { + t.Errorf("got %+v", spec) + } +} + +func TestAddResolvesARelativeTargetForTheDaemon(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + t.Chdir(repo.path) + if cliRun([]string{"-s", "5", "."}) != 0 { + t.Fatal("add failed") + } + // The daemon reads the config from a different working directory, so + // the stored target must be absolute; the flags stay verbatim. + if got := configRawLines()[0]; got != "-s 5 "+repo.path { + t.Errorf("got %q", got) + } +} + +func TestDuplicateAddFailsAndLeavesOneEntry(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{repo.path}) != 1 { + t.Error("duplicate add should fail") + } + if cliRun([]string{"add", "-s", "5", repo.path}) != 1 { + t.Error("different flags, same repo: still a duplicate") + } + if len(configSpecs()) != 1 { + t.Errorf("got %d entries", len(configSpecs())) + } +} + +func TestBarePathIsAnImplicitAdd(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + if cliRun([]string{repo.path}) != 0 { + t.Fatal("bare add failed") + } + specs := configSpecs() + if len(specs) != 1 || specs[0].Path != repo.path { + t.Errorf("got %+v", specs) + } +} + +func TestRmMatchesByName(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", repo.spec().Name()}) != 0 { + t.Error("rm by name failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmMatchesByFullPath(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", repo.path}) != 0 { + t.Error("rm by path failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmUnknownFailsAndLeavesConfigAlone(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", "not-a-watched-repo"}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 1 { + t.Error("the config must be untouched") + } +} + +func TestPauseAndResumeFlipPausedInConfig(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{"-s", "5", repo.path}) + if cliRun([]string{"pause", repo.spec().Name()}) != 0 { + t.Fatal("pause failed") + } + if !configSpecs()[0].Paused { + t.Error("spec should be paused") + } + if got := configRawLines()[0]; got != "--paused -s 5 "+repo.path { + t.Errorf("the rest of the line must survive untouched, got %q", got) + } + if cliRun([]string{"resume", repo.spec().Name()}) != 0 { + t.Fatal("resume failed") + } + if configSpecs()[0].Paused { + t.Error("spec should be resumed") + } + if got := configRawLines()[0]; got != "-s 5 "+repo.path { + t.Errorf("got %q", got) + } +} + +func TestPauseByFullPath(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"pause", repo.path}) != 0 { + t.Fatal("pause failed") + } + if !configSpecs()[0].Paused { + t.Error("spec should be paused") + } +} + +func TestPausingTwiceFailsPolitely(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + cliRun([]string{"pause", repo.path}) + before := configRawLines() + if cliRun([]string{"pause", repo.path}) != 1 { + t.Error("expected failure") + } + after := configRawLines() + if strings.Join(before, "\n") != strings.Join(after, "\n") { + t.Error("the config must be unchanged") + } +} + +func TestPauseUnknownFails(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"pause", "nothing-here"}) != 1 { + t.Error("expected failure") + } +} + +func TestVersionDoesNotFallThroughToAdd(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"version"}) != 0 || cliRun([]string{"--version"}) != 0 { + t.Error("version should succeed") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be added") + } +} + +func TestBrokenConfigEntriesSurfaceInLoadAndStatus(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + configAppend(filepath.Join(t.TempDir(), "vanished-repo")) + configAppend("-z bogus /tmp/x") + specs, errs := configLoad() + if len(specs) != 1 { + t.Errorf("the healthy repo is unaffected, got %d", len(specs)) + } + if len(errs) != 2 { + t.Fatalf("got %d errors", len(errs)) + } + foundMissing, foundUnknown := false, false + for _, e := range errs { + if e.Reason == "repo not found" { + foundMissing = true + } + if strings.Contains(e.Reason, "unknown flag") { + foundUnknown = true + } + } + if !foundMissing || !foundUnknown { + t.Errorf("got %+v", errs) + } + if cliRun([]string{"status"}) != 0 { + t.Error("status renders them rather than crashing or hiding them") + } +} diff --git a/linux/config.go b/linux/config.go new file mode 100644 index 0000000..e859e9c --- /dev/null +++ b/linux/config.go @@ -0,0 +1,253 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "unicode" +) + +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 +} + +// 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) +} + +// Non-comment, non-empty raw lines. +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 +} + +// Parsed specs (invalid lines are skipped; surface them via configLineErrors). +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 +} + +// Config lines that don't parse into a spec at all, with the reason. +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 +} + +// One pass over the config: the watchable specs, plus every entry that +// can't be watched (unparseable line, missing path, not a git repo). +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 +} + +// Append a repo line (raw gitwatch args). Creates the file if needed. +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) +} + +// True if `spec` is the repo the user means by `needle`: full path, +// tilde path, or folder name. +func configMatches(spec *RepoSpec, needle string) bool { + return spec.Path == expandTilde(needle) || spec.Name() == needle || spec.Path == needle +} + +// Remove matching lines; returns how many were removed. +func configRemove(needle 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, needle) { + 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 `needle` (by full +// path or repo name, like remove). Pause lives in the config, not daemon +// state, so it survives daemon and machine restarts. Returns the number +// of lines changed. +func configSetPaused(needle 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, needle) { + 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 +} + +// The pure rewrite behind configSetPaused: if `line` watches `path` and its +// paused state differs, return the line with --paused added (in front) +// or removed; else ok=false for "leave this line alone". +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 +} + +// The tokenizer has no escape syntax: a value with both quote kinds +// cannot round-trip. +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: + // Inside quotes: the matching quote closes; everything else is literal. + if ch == openQuote { + openQuote = 0 + } else { + current.WriteRune(ch) + } + case ch == '"' || ch == '\'': + openQuote = ch // start a quoted section + case unicode.IsSpace(ch): + finishArg() // whitespace separates arguments + default: + current.WriteRune(ch) + } + } + finishArg() + return args +} diff --git a/linux/config_test.go b/linux/config_test.go new file mode 100644 index 0000000..5b8dcab --- /dev/null +++ b/linux/config_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "testing" +) + +// Pause is config-level state: a --paused token on the repo's line, so a +// paused repo stays paused across daemon and machine restarts. These tests +// pin the parsing and the pure line-rewrite pause/resume use. + +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]) + } + } +} diff --git a/linux/daemon.go b/linux/daemon.go new file mode 100644 index 0000000..8fff42b --- /dev/null +++ b/linux/daemon.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// The headless daemon: one process, one flock-guarded pidfile, a watcher per +// configured repo, and a live reload whenever ~/.gitwatchd changes. Under +// systemd its stdout/stderr go to the journal; that is the log. + +func runDaemon() int { + lock, err := acquireDaemonLock() + if err != nil { + fmt.Fprintln(os.Stderr, "✗ daemon already running") + return 1 + } + defer lock.Close() + + logf := func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + } + logf("gitwatchd %s: watching config %s", version, configPath()) + + watchers := map[string]*repoWatcher{} // by repo path + prevSpecs := map[string]*RepoSpec{} // includes paused entries + + reload := func() { + specs, errs := configLoad() + for _, e := range errs { + logf("config: %s: %s", e.Label, e.Reason) + } + + desired := map[string]*RepoSpec{} + for _, s := range specs { + desired[s.Path] = s + } + + keep := map[string]bool{} + for path := range desired { + keep[path] = true + } + statePrune(keep) + + for path, w := range watchers { + s, wanted := desired[path] + if wanted && !s.Paused && s.Raw == w.spec.Raw { + continue + } + w.stopWatching() + delete(watchers, path) + logf("stopped watching %s", w.spec.Name()) + } + + for path, s := range desired { + if s.Paused { + continue + } + if _, running := watchers[path]; running { + continue + } + w, err := newRepoWatcher(s, stateSet, logf) + if err != nil { + logf("could not watch %s: %v", s.Name(), err) + continue + } + watchers[path] = w + w.publish() // surface watch-registration problems immediately + logf("watching %s (%s)", s.Name(), s.Path) + + prev, existed := prevSpecs[path] + resumed := existed && prev.Paused + if s.CommitOnStart || resumed { + w.flushNow() // -f, or a resume catching up on what piled up + } + } + + prevSpecs = desired + } + + reload() + + configEvents := make(chan struct{}, 1) + go watchConfigFile(configEvents) + go func() { + for range configEvents { + time.Sleep(300 * time.Millisecond) // let editors finish the save + drain(configEvents) + reload() + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + for _, w := range watchers { + w.stopWatching() + } + logf("gitwatchd stopped") + return 0 +} + +func drain(ch chan struct{}) { + for { + select { + case <-ch: + default: + return + } + } +} + +// Watch the config file's directory and signal when the file changes +// (editors typically replace the file, so watching the path itself breaks). +func watchConfigFile(events chan struct{}) { + fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) + if err != nil { + return + } + dir := filepath.Dir(configPath()) + base := filepath.Base(configPath()) + if _, err := syscall.InotifyAddWatch(fd, dir, watchMask); err != nil { + syscall.Close(fd) + return + } + buf := make([]byte, 64*1024) + for { + n, err := syscall.Read(fd, buf) + if err != nil || n <= 0 { + return + } + if inotifyNamesContain(buf[:n], base) { + select { + case events <- struct{}{}: + default: + } + } + } +} + +func inotifyNamesContain(buf []byte, want string) bool { + offset := 0 + for offset+syscall.SizeofInotifyEvent <= len(buf) { + lenField := int(uint32frombytes(buf[offset+12 : offset+16])) + name := "" + if lenField > 0 { + raw := buf[offset+syscall.SizeofInotifyEvent : offset+syscall.SizeofInotifyEvent+lenField] + name = strings.TrimRight(string(raw), "\x00") + } + if name == want { + return true + } + offset += syscall.SizeofInotifyEvent + lenField + } + return false +} + +func uint32frombytes(b []byte) uint32 { + return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 +} + +// Single-instance guard: the daemon holds an exclusive flock on the pidfile +// for its whole life, so liveness checks can't be fooled by stale pids. +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 +} diff --git a/linux/daemon_test.go b/linux/daemon_test.go new file mode 100644 index 0000000..30841fa --- /dev/null +++ b/linux/daemon_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// End-to-end through the real binary: config written by the CLI, daemon +// started detached, inotify driving commits, live config reload, and a clean +// stop. One flow, because this is exactly the session a user has. + +func TestDaemonEndToEnd(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := isolatedEnv(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + + if code, out := runCLI(env, "start"); code != 0 || !strings.Contains(out, "✓ daemon started") { + t.Fatalf("start: code=%d out=%s", code, out) + } + if code, out := runCLI(env, "start"); code != 0 || !strings.Contains(out, "daemon already running") { + t.Fatalf("second start: code=%d out=%s", code, out) + } + _, out := runCLI(env, "status") + if !strings.Contains(out, "daemon: running") { + t.Fatalf("status should show the daemon running:\n%s", out) + } + + repo.write("notes.txt", "hello\n") + waitFor(t, 15*time.Second, "the daemon's auto-commit", func() bool { return repo.commitCount() == 1 }) + if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { + t.Errorf("got message %q", repo.lastMessage()) + } + time.Sleep(1 * time.Second) + if repo.commitCount() != 1 { + t.Error("the daemon retriggered on its own commit") + } + + _, out = runCLI(env, "status") + if !strings.Contains(out, "repo · main") { + t.Errorf("status should show the watched row:\n%s", out) + } + + // Live reload: adding a second repo while the daemon runs starts + // watching it without a restart. + 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") }) + + // Pause stops commits; resume catches up on what piled up meanwhile. + 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") +} + +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 := isolatedEnv(home) + // A PATH with no systemctl. + 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)) + } + } + for i, e := range env { + if strings.HasPrefix(e, "PATH=") { + env[i] = "PATH=" + bindir + } + } + code, out := runCLI(env, "autostart", "on") + if code == 0 { + t.Error("autostart on must fail without systemd") + } + if !strings.Contains(out, "systemd not found") || !strings.Contains(out, "gitwatchd start") { + t.Errorf("the message must explain and point to `gitwatchd start`:\n%s", out) + } + if _, err := os.Stat(filepath.Join(home, ".config", "systemd")); err == nil { + t.Error("no unit may be written when systemd is absent") + } + if entries, _ := os.ReadDir(home); len(entries) > 2 { // bin/ and nothing else unexpected + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("no dotfiles may be touched, found %v", names) + } +} + +func 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 killDaemonIfRunning(home string) { + raw, err := os.ReadFile(filepath.Join(home, "state", "gitwatchd.pid")) + if err != nil { + return + } + if pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))); pid > 0 { + syscall.Kill(pid, syscall.SIGKILL) + } +} diff --git a/linux/flagcontract_test.go b/linux/flagcontract_test.go new file mode 100644 index 0000000..1f83a8e --- /dev/null +++ b/linux/flagcontract_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "strings" + "testing" +) + +// The help text states flag defaults and exclusion behaviour as facts; +// these tests pin them so the help can't silently drift from the code. + +func TestDefaultsForBarePath(t *testing.T) { + spec, errMsg := parseRepoSpec([]string{"/tmp/x"}) + if spec == nil { + t.Fatal(errMsg) + } + if spec.Settle != 2 { // -s "Default: 2" + t.Errorf("settle = %v", spec.Settle) + } + if spec.Remote != "" { // -r "Default: no push" + t.Errorf("remote = %q", spec.Remote) + } + if spec.Message != "gitwatchd auto-commit (%d)" { + t.Errorf("message = %q", spec.Message) + } + if spec.DateFormat != "+%Y-%m-%d %H:%M:%S" { // upstream's exact default, leading + included + t.Errorf("dateFormat = %q", spec.DateFormat) + } + if spec.Branch != "" || spec.Rebase || spec.Exclude != "" || spec.NoMergeCommit || spec.Paused { + t.Errorf("unexpected non-default: %+v", spec) + } + if spec.CommitOnStart { + t.Error("-f is opt-in: a deliberate manual state is not flushed") + } +} + +func TestPIsAnAliasOfR(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-p", "origin", "/tmp/x"}) + if spec == nil || spec.Remote != "origin" { + t.Errorf("got %+v", spec) + } +} + +func TestSettleRejectsBadValues(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); spec != nil { + t.Error("-s -1 should be rejected") + } + if spec, _ := parseRepoSpec([]string{"-s", "soon", "/tmp/x"}); spec != nil { + t.Error("-s soon should be rejected") + } + if _, msg := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); msg != "-s needs a number of seconds, 0 or more" { + t.Errorf("wrong message: %q", msg) + } + spec, _ := parseRepoSpec([]string{"-s", "0", "/tmp/x"}) + if spec == nil || spec.Settle != 0 { + t.Error("-s 0 is valid") + } +} + +func TestExcludeMatchesAnywhereInPath(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/deep/dir/debug.log") { + t.Error("should match a nested .log file") + } + if spec.Excludes("/tmp/x/log.txt") { + t.Error("should not match log.txt") + } +} + +func TestExcludeDirectoryPattern(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", "build/", "/tmp/x"}) + if !spec.Excludes("/tmp/x/build/out.o") { + t.Error("should exclude the build subtree") + } + if spec.Excludes("/tmp/x/src/out.o") { + t.Error("should not exclude src") + } +} + +func TestLastExcludeWins(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "-x", `\.tmp$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/b.tmp") { + t.Error("the last -x should apply") + } + if spec.Excludes("/tmp/x/a.log") { + t.Error("the first -x should be forgotten, as upstream") + } +} + +func TestInvalidExcludeIsAParseError(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-x", "*.log", "/tmp/x"}); spec != nil { + t.Error("an invalid regex must be a parse error, not a silent no-op") + } +} + +func TestNoExcludeByDefault(t *testing.T) { + spec, _ := parseRepoSpec([]string{"/tmp/x"}) + if spec.Excludes("/tmp/x/anything.log") { + t.Error("nothing is excluded without -x") + } +} + +// The help is the contract; these hold it to the implementation. The +// canonical lists below ARE the documented surface: adding a flag or command +// to only one side of the contract fails one of these tests. + +var contractValueFlags = map[string]string{ + "-s": "2", "-r": "origin", "-b": "main", "-m": "msg", + "-d": "+%Y", "-x": `\.log$`, "-g": "/tmp/gd", +} +var contractBoolFlags = []string{"-R", "-M", "-f", "--paused"} +var contractCommands = []string{"add", "rm", "pause", "resume", "status", + "start", "stop", "autostart", "config", "help", "version"} + +func TestDocumentedFlagsParse(t *testing.T) { + for flag, value := range contractValueFlags { + if spec, msg := parseRepoSpec([]string{flag, value, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } + for _, flag := range contractBoolFlags { + if spec, msg := parseRepoSpec([]string{flag, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } +} + +func TestContractAppearsInHelp(t *testing.T) { + for flag := range contractValueFlags { + if !strings.Contains(usageText, flag+" <") { + t.Errorf("help is missing %s with its ", flag) + } + } + for _, flag := range contractBoolFlags { + if !strings.Contains(usageText, flag) { + t.Errorf("help is missing %s", flag) + } + } + for _, command := range contractCommands { + if !strings.Contains(usageText, command) { + t.Errorf("help is missing the %s command", command) + } + } +} + +func TestNoPhantomFlagsInHelp(t *testing.T) { + known := map[string]bool{} + for flag := range contractValueFlags { + known[flag] = true + } + for _, flag := range contractBoolFlags { + known[flag] = true + } + for _, line := range strings.Split(usageText, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "-") { + continue + } + flag := strings.Fields(line)[0] + if !known[flag] { + t.Errorf("help documents %s, which the contract list doesn't know", flag) + } + } +} diff --git a/linux/formatting_test.go b/linux/formatting_test.go new file mode 100644 index 0000000..acfb48e --- /dev/null +++ b/linux/formatting_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "testing" + "time" +) + +// 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[OutcomeKind]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, 300 * time.Second, 300 * time.Second} + 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) + } + } +} + +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/git.go b/linux/git.go new file mode 100644 index 0000000..d8ecda9 --- /dev/null +++ b/linux/git.go @@ -0,0 +1,254 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Thin wrapper around git. No external deps: this plus inotify is the whole +// engine. Behavior mirrors gitwatch: debounced auto-commit, optional push to a +// remote/branch, optional pull --rebase, optional merge-commit guard. + +// What one auto-commit cycle (or push retry) accomplished. The git commands +// and their order are gitwatch's; this only reports the result. +type OutcomeKind int + +const ( + Clean OutcomeKind = 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 OutcomeKind + 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 "" +} + +// Run a program and capture stdout+stderr, trimmed. +func runCommand(exe string, args []string, cwd string) (int, string) { + cmd := exec.Command(exe, args...) + if cwd != "" { + cmd.Dir = cwd + } + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil { + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode(), text + } + return -1, err.Error() + } + return 0, text +} + +func gitRun(args []string, dir string, gitDir string) (int, string) { + full := args + // -g repos get upstream's exact override on every call: + // `git --work-tree $TARGETDIR --git-dir $GIT_DIR `. + if gitDir != "" { + full = append([]string{"--work-tree", dir, "--git-dir", gitDir}, args...) + } + return runCommand("git", full, dir) +} + +func isRepo(dir string, gitDir string) bool { + _, out := gitRun([]string{"rev-parse", "--is-inside-work-tree"}, dir, gitDir) + return out == "true" +} + +func currentBranch(dir string, gitDir string) string { + code, out := gitRun([]string{"rev-parse", "--abbrev-ref", "HEAD"}, dir, gitDir) + if code != 0 { + return "?" + } + return out +} + +func pendingCount(dir string, gitDir string) int { + _, out := gitRun([]string{"status", "--porcelain"}, dir, gitDir) + if out == "" { + return 0 + } + return len(strings.Split(out, "\n")) +} + +func lastCommitSummary(dir string, gitDir string) string { + code, out := gitRun([]string{"log", "-1", "--pretty=%cr · %s"}, dir, gitDir) + if code != 0 { + return "no commits yet" + } + return out +} + +// True if a state file/dir exists inside the repo's resolved .git dir. +func gitStateExists(names []string, dir string, gitDir string) bool { + code, top := gitRun([]string{"rev-parse", "--git-dir"}, dir, gitDir) + if code != 0 { + return false + } + gitDirPath := top + if !filepath.IsAbs(gitDirPath) { + gitDirPath = filepath.Join(dir, gitDirPath) + } + for _, name := range names { + if _, err := os.Stat(filepath.Join(gitDirPath, name)); err == nil { + return true + } + } + return false +} + +// gitwatch's is_merging: MERGE_HEAD only (a rebase does not count, upstream). +func hasMergeInProgress(dir string, gitDir string) bool { + return gitStateExists([]string{"MERGE_HEAD"}, dir, gitDir) +} + +func hasRebaseInProgress(dir string, gitDir string) bool { + return gitStateExists([]string{"rebase-merge", "rebase-apply"}, dir, gitDir) +} + +// Stage all, commit (honoring -m/-d/-M), then optionally pull --rebase (-R) +// and push (-r/-b). One gitwatch cycle. +func autoCommit(spec *RepoSpec) Outcome { + dir := spec.WorkDir() + if spec.NoMergeCommit && hasMergeInProgress(dir, spec.GitDir) { + return Outcome{Kind: SkippedMerge} + } + if pendingCount(dir, spec.GitDir) == 0 { + return Outcome{Kind: Clean} + } + + // Upstream builds the message before `git add`, and so do we: the -c/-C + // message command (not ported yet) has to see the unstaged tree. + // Upstream's ${COMMITMSG/\%d/...}: the date splices into the first %d only. + msg := strings.Replace(spec.Message, "%d", formattedDate(spec.DateFormat), 1) + + // 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) + code, out := gitRun([]string{"commit", "-m", msg}, dir, spec.GitDir) + + // gitwatch runs the pull (-R) and the push unconditionally after the + // commit, whether or not it succeeded, so a repo whose commit a hook + // blocks still pushes what is already committed. commitOutcome is + // reporting only. + 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)} + } + } + + // No remote: never call push(), whose no-remote return would mask a + // commit failure. + if spec.Remote == "" { + return commitOutcome + } + pushed := push(spec) + if commitOutcome.Kind == CommitFailed { + return commitOutcome + } + return pushed +} + +// The push stage of a cycle, exactly as gitwatch runs it. With -R, first +// `git pull --rebase ` (no branch argument, exit code ignored, no +// abort: a conflict leaves the rebase in progress for the user to resolve, +// and -M is the only guard). Then the push, which upstream runs regardless +// of how the pull went. Split out from autoCommit so a failed push can be +// retried without re-running the commit stage. +// +// Everything below the git calls is reporting only: gitwatch ignores both +// results; we classify them for status. +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. Internal so tests can pin the forms. +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} +} + +// The most informative line of a failed command's output: the first +// error/fatal/rejection line if there is one, else the last non-empty line. +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" +} diff --git a/linux/gitengine_test.go b/linux/gitengine_test.go new file mode 100644 index 0000000..76b0777 --- /dev/null +++ b/linux/gitengine_test.go @@ -0,0 +1,371 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Engine tests drive autoCommit / push against real throwaway git repos, +// asserting both the reported outcome and the repo state left behind +// (the gitwatch parity contract: same commands, same end state). + +func TestCleanRepoDoesNothing(t *testing.T) { + repo := newTestRepo(t) + repo.write("seed.txt", "v1") + autoCommit(repo.spec()) // absorb the initial commit + if got := autoCommit(repo.spec()); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Error("no extra commit should appear") + } +} + +func TestChangesCommitLocallyWithoutRemote(t *testing.T) { + repo := newTestRepo(t) + repo.write("notes.txt", "hello") + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Errorf("commits = %d", repo.commitCount()) + } + if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { + t.Errorf("default message expected, got: %s", repo.lastMessage()) + } +} + +func TestCustomMessageAndDateExpansion(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved on %d", "-d", "+%Y")) + if !strings.HasPrefix(repo.lastMessage(), "saved on 2") { // "saved on 2026" + t.Errorf("got: %s", repo.lastMessage()) + } +} + +// Sharp corner, kept for upstream parity: -d goes to date(1) raw, so a +// format without a leading + splices an empty date. Git's commit-message +// cleanup then trims the trailing whitespace. +func TestSharpCornerRawDateFormatWithoutPlusSplicesEmptyDate(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "at %d", "-d", "%Y")) + if repo.lastMessage() != "at" { + t.Errorf("got %q, want %q", repo.lastMessage(), "at") + } +} + +func TestOnlyTheFirstDateTokenIsExpanded(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved %d then %d", "-d", "+%Y")) + got := repo.lastMessage() + if !strings.HasPrefix(got, "saved 2") || !strings.HasSuffix(got, " then %d") { + t.Errorf("upstream splices the date into the first %%d only, got: %s", got) + } +} + +func TestPushToRemote(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the remote should have the commit") + } +} + +func TestUnreachableRemoteReportsPushFailedButCommitSurvives(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "not-a-remote.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if got.Detail == "" { + t.Error("the git error is captured for status") + } + if repo.commitCount() != 1 { + t.Error("gitwatch parity: commit stays, only the push failed") + } +} + +func TestPushRetriesStrandedCommitAfterOutage(t *testing.T) { + repo := newTestRepo(t) + origin := newBareRemote(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "offline.git")) // remote "down" + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != PushFailed { + t.Fatalf("setup: expected the first push to fail, got %+v", got) + } + repo.setOriginURL(origin.path) // remote "back up" + if got := push(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the earlier commit reached the remote") + } +} + +func TestPullFailureWhileOfflineIsTransientNotConflict(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if repo.midRebase() { + t.Error("no rebase was ever started, so the retry loop may heal this") + } +} + +func TestIdempotentRetryStillReportsPushed(t *testing.T) { + repo := newTestRepo(t) + repo.addOrigin() + repo.write("a.txt", "1") + spec := repo.spec("-r", "origin", "-b", "main") + autoCommit(spec) + if got := push(spec); got.Kind != Pushed { // "Everything up-to-date" + t.Errorf("got %+v", got) + } +} + +func TestPushFormWithoutBranch(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-r", "origin", "/tmp/x"}) + got := pushArgs("origin", spec) + if strings.Join(got, " ") != "push origin" { + t.Errorf("got %v", got) + } +} + +func TestPushFormWithBranch(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "-b", "feature") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin feature:main" { + t.Errorf("got %v", got) + } +} + +func TestPushFormFromDetachedHead(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "--detach") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin main" { + t.Errorf("got %v", got) + } +} + +func TestRefspecEndToEnd(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.git("checkout", "-q", "-b", "feature") + repo.write("b.txt", "on feature\n") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-m", "from feature")); got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 2 { + t.Error("the remote's main received the feature commit") + } + if origin.lastMessage() != "from feature" { + t.Errorf("got %q", origin.lastMessage()) + } +} + +func TestRebaseThenPush(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("ours.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) + colleague.write("theirs.txt", "from the other machine\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main", "-m", "made elsewhere")) + + repo.write("ours.txt", "updated here\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R", "-m", "made here")) + if got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 3 { + t.Error("base, theirs, ours: one linear history") + } + if origin.lastMessage() != "made here" { + t.Error("our commit was rebased on top") + } + if !strings.Contains(repo.git("log", "--pretty=%s"), "made elsewhere") { + t.Error("their commit is now part of our local history") + } +} + +func TestMergeGuardSkipsMidMerge(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if !repo.midMerge() { + t.Fatal("setup: repo should be mid-merge") + } + if got := autoCommit(repo.spec("-M")); got.Kind != SkippedMerge { + t.Errorf("got %+v", got) + } + if !repo.midMerge() { + t.Error("the merge is left exactly as it was") + } +} + +func TestWithoutMergeGuardTheMergeIsCommitted(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.midMerge() { + t.Error("the commit concluded the merge, as gitwatch would") + } +} + +func TestSubdirectoryTargetCommitsOnlyTheSubtree(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("outer.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "sub/inner.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestFileTargetCommitsOnlyThatFile(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "a.txt")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("b.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "a.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestOutsideChangesOnlyReportClean(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("outer.txt", "elsewhere\n") + before := repo.commitCount() + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != before { + t.Error("no commit should appear") + } +} + +func TestDetachedGitDir(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "base\n") + autoCommit(repo.spec()) + gitDir := filepath.Join(t.TempDir(), "elsewhere.git") + if err := os.Rename(filepath.Join(repo.path, ".git"), gitDir); err != nil { + t.Fatal(err) + } + spec := repo.spec("-g", gitDir) + if !isRepo(repo.path, gitDir) { + t.Error("the daemon/CLI validation path must accept a -g repo") + } + repo.write("a.txt", "changed\n") + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if _, out := gitRun([]string{"rev-list", "--count", "HEAD"}, repo.path, gitDir); out != "2" { + t.Errorf("commits = %s", out) + } + if got := autoCommit(spec); got.Kind != Clean { + t.Error("and the change was fully committed") + } +} + +func TestFailingPreCommitHookReportsCommitFailed(t *testing.T) { + repo := newTestRepo(t) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "1") + got := autoCommit(repo.spec()) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } +} + +// The push still runs after a failed commit (gitwatch parity), but its result +// must not mask the commit failure: the menu/status row has to say "commit +// failing", not "pushed". +func TestFailingCommitIsStillReportedWhenThePushSucceeds(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("seed.txt", "1") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "2") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } + if origin.commitCount() != 1 || repo.commitCount() != 1 { + t.Error("the blocked commit never happened, and the push had nothing new to send") + } +} + +func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("shared.txt", "original\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) // someone else pushes first + colleague.write("shared.txt", "colleague's version\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main")) + + repo.write("shared.txt", "our conflicting version\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != RebaseConflict { + t.Fatalf("expected rebaseConflict, got %+v", got) + } + // gitwatch parity: no abort. The conflicted rebase is left in progress + // for the user to resolve; we only make it visible in status. + if !repo.midRebase() { + t.Error("the conflicted rebase is left in progress") + } +} diff --git a/linux/helpers_test.go b/linux/helpers_test.go new file mode 100644 index 0000000..b466d4d --- /dev/null +++ b/linux/helpers_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A throwaway git repo for the engine to run against: real git, real commits, +// so tests assert both the reported outcome and the repo state left behind. +type testRepo struct { + t *testing.T + path string +} + +func newTestRepo(t *testing.T) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "repo")} + os.MkdirAll(r.path, 0o755) + r.git("init", "-q", "-b", "main") + r.configureUser() + return r +} + +// A second working copy of a remote (a colleague's machine). +func newCloneOf(t *testing.T, remote *bareRemote) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "clone")} + runCommand("git", []string{"clone", "-q", remote.path, r.path}, "") + r.configureUser() + return r +} + +func (r *testRepo) configureUser() { + r.git("config", "user.email", "tests@gitwatchd.local") + r.git("config", "user.name", "gitwatchd tests") + r.git("config", "commit.gpgsign", "false") +} + +// The spec `gitwatchd add ` would produce. +func (r *testRepo) spec(flags ...string) *RepoSpec { + r.t.Helper() + spec, errMsg := parseRepoSpec(append(flags, r.path)) + if spec == nil { + r.t.Fatalf("spec did not parse: %s", errMsg) + } + return spec +} + +func (r *testRepo) git(args ...string) string { + _, out := gitRun(args, r.path, "") + return out +} + +func (r *testRepo) write(file, contents string) { + r.t.Helper() + full := filepath.Join(r.path, file) + os.MkdirAll(filepath.Dir(full), 0o755) + if err := os.WriteFile(full, []byte(contents), 0o644); err != nil { + r.t.Fatal(err) + } +} + +func (r *testRepo) commitCount() int { + n, _ := strconv.Atoi(r.git("rev-list", "--count", "HEAD")) + return n +} + +func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%s") } + +func (r *testRepo) midMerge() bool { + _, err := os.Stat(filepath.Join(r.path, ".git", "MERGE_HEAD")) + return err == nil +} + +func (r *testRepo) midRebase() bool { + for _, d := range []string{"rebase-merge", "rebase-apply"} { + if _, err := os.Stat(filepath.Join(r.path, ".git", d)); err == nil { + return true + } + } + return false +} + +// Wire up an "origin" this repo pushes to (a real bare repo). +func (r *testRepo) addOrigin() *bareRemote { + remote := newBareRemote(r.t) + r.git("remote", "add", "origin", remote.path) + return remote +} + +// An "origin" pointing at a URL that does not exist (an unreachable 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 +} + +// A bare repo standing in for the server side (GitHub etc). +type bareRemote struct { + path string +} + +func newBareRemote(t *testing.T) *bareRemote { + t.Helper() + b := &bareRemote{path: filepath.Join(t.TempDir(), "remote.git")} + runCommand("git", []string{"init", "-q", "--bare", "-b", "main", b.path}, "") + return b +} + +func (b *bareRemote) commitCount() int { + code, out := gitRun([]string{"rev-list", "--count", "main"}, b.path, "") + if code != 0 { + return 0 + } + n, _ := strconv.Atoi(out) + return n +} + +func (b *bareRemote) lastMessage() string { + _, out := gitRun([]string{"log", "-1", "--pretty=%s", "main"}, b.path, "") + return out +} + +// The built gitwatchd binary, for tests that exercise the real daemon. +var testBinary string + +func TestMain(m *testing.M) { + 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) +} + +// Run the built binary with an isolated HOME/config/state and return its +// exit code and combined output. +func runCLI(env []string, args ...string) (int, string) { + cmd := exec.Command(testBinary, 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") +} diff --git a/linux/main.go b/linux/main.go index 0d89a27..515d85c 100644 --- a/linux/main.go +++ b/linux/main.go @@ -1,11 +1,7 @@ package main -import ( - "fmt" - "os" -) +import "os" func main() { - fmt.Fprintln(os.Stderr, "gitwatchd: not implemented on Linux yet") - os.Exit(1) + os.Exit(cliRun(os.Args[1:])) } diff --git a/linux/parity_test.go b/linux/parity_test.go new file mode 100644 index 0000000..a70f235 --- /dev/null +++ b/linux/parity_test.go @@ -0,0 +1,316 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Differential parity tests: the same scenario runs through upstream +// gitwatch.sh (the model) and through our engine, each on its own +// identically-built world, and the observable git state afterwards must be +// identical. Scenario setup uses only plain git commands, so neither +// implementation touches the world until the measured cycle. +// +// Determinism: gitwatch's -f performs one commit cycle before entering its +// watch loop, and GW_INW_BIN lets us point the watcher at a stub that exits +// immediately, so the script does exactly one cycle and terminates. Every +// scenario passes an explicit -m without %d, since default messages and +// timestamps intentionally differ. + +// Everything a user could observe about a world after one cycle. +type repoState struct { + commitCount int + lastMessage string + pendingChanges int + branch string + midMerge bool + midRebase bool + remoteCommitCount int // -1 when the scenario has no remote + remoteLastMessage string +} + +func (s repoState) String() string { + return fmt.Sprintf("commits=%d last=%q pending=%d branch=%s midMerge=%v midRebase=%v remote(commits=%d last=%q)", + s.commitCount, s.lastMessage, s.pendingChanges, s.branch, + s.midMerge, s.midRebase, s.remoteCommitCount, s.remoteLastMessage) +} + +func stateOf(repo *testRepo, remote *bareRemote) repoState { + s := repoState{ + commitCount: repo.commitCount(), + lastMessage: repo.lastMessage(), + pendingChanges: pendingCount(repo.path, ""), + branch: currentBranch(repo.path, ""), + midMerge: repo.midMerge(), + midRebase: repo.midRebase(), + remoteCommitCount: -1, + } + if remote != nil { + s.remoteCommitCount = remote.commitCount() + s.remoteLastMessage = remote.lastMessage() + } + return s +} + +// The upstream script, vendored once for the whole repo in the macOS test +// suite; both implementations measure themselves against that same copy. +func gitwatchScript(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + script := filepath.Join(wd, "..", "macos", "Tests", "Reference", "gitwatch.sh") + if _, err := os.Stat(script); err != nil { + t.Fatalf("vendored gitwatch.sh not found at %s", script) + } + return script +} + +// A watcher stub that exits immediately: gitwatch runs its -f startup +// commit, the watch pipe hits EOF, and the script terminates. +func stubWatcher(t *testing.T) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "inotifywait") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + return stub +} + +// Run upstream gitwatch for exactly one commit cycle on `target`. +func runOneCycle(t *testing.T, flags []string, target string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + args := append([]string{gitwatchScript(t), "-f"}, flags...) + args = append(args, target) + cmd := exec.CommandContext(ctx, "bash", args...) + cmd.Env = append(os.Environ(), "GW_INW_BIN="+stubWatcher(t)) + cmd.Run() // fire-and-forget, like gitwatch itself +} + +// Build two identical worlds, run the model on one and our engine on the +// other with the same flags, and return both fingerprints. +func twins(t *testing.T, flags []string, withRemote bool, targetSuffix string, + setup func(*testRepo, *bareRemote)) (model, ours repoState) { + t.Helper() + target := func(repo *testRepo) string { + if targetSuffix != "" { + return filepath.Join(repo.path, targetSuffix) + } + return repo.path + } + + a := newTestRepo(t) + var ra *bareRemote + if withRemote { + ra = a.addOrigin() + } + setup(a, ra) + runOneCycle(t, flags, target(a)) + model = stateOf(a, ra) + + b := newTestRepo(t) + var rb *bareRemote + if withRemote { + rb = b.addOrigin() + } + setup(b, rb) + spec, errMsg := parseRepoSpec(append(flags, target(b))) + if spec == nil { + t.Fatal(errMsg) + } + autoCommit(spec) + ours = stateOf(b, rb) + return model, ours +} + +func expectParity(t *testing.T, model, ours repoState) { + t.Helper() + if model != ours { + t.Errorf("state diverged\n gitwatch: %v\n ours: %v", model, ours) + } +} + +// Plain-git scenario builders (no engine involvement). +func seed(repo *testRepo) { + repo.write("seed.txt", "seed\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") +} + +func seedAndPush(repo *testRepo) { + seed(repo) + repo.git("push", "-q", "origin", "main") + repo.trackOrigin() +} + +func colleaguePushes(t *testing.T, remote *bareRemote, file, message string) { + colleague := newCloneOf(t, remote) + colleague.write(file, "from the other machine\n") + colleague.git("add", "-A") + colleague.git("commit", "-q", "-m", message) + colleague.git("push", "-q", "origin", "main") +} + +func TestParityCleanRepo(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + }) + expectParity(t, model, ours) + if ours.commitCount != 1 { + t.Errorf("neither side commits anything: %v", ours) + } +} + +func TestParityPlainCommit(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.lastMessage != "cycle" || ours.pendingChanges != 0 { + t.Errorf("same commit, same message, clean tree afterwards: %v", ours) + } +} + +// Sharp corner, kept for upstream parity: -d passes to date(1) raw and only +// the first %d is spliced. The year-only format keeps the spliced date +// identical across the two runs, so the fingerprints compare exactly. +func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { + model, ours := twins(t, []string{"-m", "at %d then %d", "-d", "+%Y"}, false, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if !strings.HasPrefix(ours.lastMessage, "at 2") || !strings.HasSuffix(ours.lastMessage, " then %d") { + t.Errorf("got %q", ours.lastMessage) + } +} + +func TestParityPushToRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seedAndPush(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 2 || ours.remoteLastMessage != "cycle" { + t.Errorf("both push the commit: %v", ours) + } +} + +func TestParityUnreachableRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.setOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 { + t.Error("fire-and-forget: the commit still happens") + } +} + +// Upstream runs the pull and the push after `git commit` whether or not the +// commit succeeded, so a repo whose commit a hook blocks still pushes what is +// already committed. The seed here is deliberately left unpushed, which is what +// lets the two worlds disagree if we ever short-circuit on a failed commit. +func TestParityFailedCommitStillPushes(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) // committed locally, never pushed + repo.installFailingPreCommitHook("lint says no") + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 1 { + t.Errorf("the already-committed seed reaches the remote on both sides: %v", ours) + } + if ours.commitCount != 1 || ours.pendingChanges == 0 { + t.Errorf("the hook blocked the new commit on both sides: %v", ours) + } +} + +func TestParityMergeGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-M"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if !ours.midMerge { + t.Error("the merge is left untouched on both sides") + } +} + +func TestParityMergeCommittedWithoutGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if ours.midMerge { + t.Error("the cycle concluded the merge on both sides") + } +} + +func TestParityRebaseHappyPath(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "theirs.txt", "made elsewhere") + repo.write("ours.txt", "made here\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 3 { + t.Error("seed, theirs, ours: one linear history") + } + if ours.remoteLastMessage != "cycle" { + t.Errorf("got %q", ours.remoteLastMessage) + } +} + +func TestParitySubdirectoryTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "sub", func(repo *testRepo, _ *bareRemote) { + repo.write("sub/inner.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("outer.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityFileTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { + repo.write("a.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("b.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityRebaseConflictLeftInProgress(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "seed.txt", "conflicting edit") + repo.write("seed.txt", "our conflicting edit\n") + }) + expectParity(t, model, ours) + if !ours.midRebase { + t.Error("both sides stop mid-rebase; -M is the only guard") + } +} diff --git a/linux/repospec.go b/linux/repospec.go new file mode 100644 index 0000000..77861aa --- /dev/null +++ b/linux/repospec.go @@ -0,0 +1,188 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// A watched-repo specification, expressed in gitwatch's own flag vocabulary so +// that gitwatch users read our config/CLI with zero translation. +// +// gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg] +// [-x pattern] [-M] [-g gitdir] [-e events] +// +// Each config line is exactly the argument string you'd pass to gitwatch. +type RepoSpec struct { + Path string + Settle float64 // -s debounce seconds + DateFormat string // -d + Remote string // -r + Branch string // -b + Rebase bool // -R pull --rebase before push + Message string // -m (%d -> date) + Exclude string // -x regex; last one wins, as upstream + NoMergeCommit bool // -M + CommitOnStart bool // -f commit pending changes when watching starts + GitDir string // -g --git-dir + Paused bool // --paused (gitwatchd extension, not gitwatch: + // config-level so a pause survives restarts) + 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) +} + +// Parse a gitwatch-style argument list into a RepoSpec. +// Returns nil + an error message if there's no valid target. +func parseRepoSpec(args []string) (*RepoSpec, string) { + spec := &RepoSpec{ + Settle: 2, + DateFormat: "+%Y-%m-%d %H:%M:%S", + Message: "gitwatchd auto-commit (%d)", + } + target := "" + haveTarget := false + i := 0 + next := func() (string, bool) { + i++ + if i < len(args) { + return args[i], true + } + return "", false + } + + for i < len(args) { + a := args[i] + switch a { + case "-s": + v, ok := next() + d, err := strconv.ParseFloat(v, 64) + if !ok || err != nil || d < 0 { + return nil, "-s needs a number of seconds, 0 or more" + } + spec.Settle = d + case "-d": + if v, ok := next(); ok { + spec.DateFormat = v + } + case "-r", "-p": // -p: upstream's alias of -r + if v, ok := next(); ok { + spec.Remote = v + } + case "-b": + if v, ok := next(); ok { + spec.Branch = v + } + case "-R": + spec.Rebase = true + case "-m": + if v, ok := next(); ok { + spec.Message = v + } + case "-x": + v, ok := next() + if !ok { + return nil, "-x needs a valid regular expression" + } + if _, err := regexp.Compile(v); err != nil { + return nil, "-x needs a valid regular expression" + } + spec.Exclude = v + case "-M": + spec.NoMergeCommit = true + case "-f": + spec.CommitOnStart = true + case "-g": + if v, ok := next(); ok { + spec.GitDir = v + } + case "-e": + next() // inotify events: accepted, no-op (we watch a fixed gitwatch-like set) + case "--paused": + spec.Paused = true // gitwatchd extension (see RepoSpec) + 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 +} + +// Render the current date exactly as upstream does: the -d value passes to +// date(1) verbatim (the user includes the leading +), and a format date(1) +// rejects yields an empty string. +func formattedDate(fmt string) string { + code, out := runCommand("date", []string{fmt}, "") + if code != 0 { + return "" + } + return out +} diff --git a/linux/state.go b/linux/state.go new file mode 100644 index 0000000..1bed416 --- /dev/null +++ b/linux/state.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +// A repo's current error, as published by the daemon. Watchers write it, and +// `gitwatchd status` is a view over it. Stored as one JSON file rewritten +// atomically; the daemon is the only writer. +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"` +} + +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 pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } +func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } + +var stateMu sync.Mutex + +func stateErrors() map[string]RepoStatus { + raw, err := os.ReadFile(statePath()) + if err != nil { + return map[string]RepoStatus{} + } + var out map[string]RepoStatus + if json.Unmarshal(raw, &out) != nil || out == nil { + return map[string]RepoStatus{} + } + return out +} + +func stateSet(repoPath string, status *RepoStatus) { + stateMu.Lock() + defer stateMu.Unlock() + errs := stateErrors() + if status == nil { + delete(errs, repoPath) + } else { + errs[repoPath] = *status + } + stateWrite(errs) +} + +func statePrune(keep map[string]bool) { + stateMu.Lock() + defer stateMu.Unlock() + errs := stateErrors() + for path := range errs { + if !keep[path] { + delete(errs, path) + } + } + stateWrite(errs) +} + +func stateWrite(errs map[string]RepoStatus) { + os.MkdirAll(stateDir(), 0o755) + raw, err := json.Marshal(errs) + if err != nil { + return + } + tmp := statePath() + ".tmp" + if os.WriteFile(tmp, raw, 0o644) == nil { + os.Rename(tmp, statePath()) + } +} diff --git a/linux/statusformat.go b/linux/statusformat.go new file mode 100644 index 0000000..1f50f49 --- /dev/null +++ b/linux/statusformat.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "math" + "time" +) + +// Pure string formatting for `gitwatchd status`. Same rows and strings as the +// macOS menu, so the habit transfers between machines unchanged. + +// 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) +} + +func truncated(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-1]) + "…" +} + +// "just now", "40s ago", "5m ago", "3h ago", "2d ago". +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))) +} + +// Push retry backoff: 30s doubling to a 5 minute cap. +const ( + backoffFirst = 30 * time.Second + backoffCap = 300 * time.Second +) + +func backoffDelay(afterFailures int) time.Duration { + if afterFailures <= 1 { + return backoffFirst + } + d := time.Duration(float64(backoffFirst) * math.Pow(2, float64(afterFailures-1))) + if d > backoffCap { + return backoffCap + } + return d +} diff --git a/linux/systemd.go b/linux/systemd.go new file mode 100644 index 0000000..bb0b223 --- /dev/null +++ b/linux/systemd.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +// Autostart = a systemd user unit, the standard way for a per-user daemon to +// survive reboots and headless boots (with lingering). When systemd is absent +// we say so and do nothing: no shell-profile edits, ever. + +func unitPath() string { + return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") +} + +func systemctlPresent() bool { + _, err := exec.LookPath("systemctl") + return err == nil +} + +func unitInstalled() bool { + _, err := os.Stat(unitPath()) + return err == nil +} + +func systemctlUser(args ...string) (int, string) { + return runCommand("systemctl", append([]string{"--user"}, args...), "") +} + +func unitActive() bool { + _, out := systemctlUser("is-active", "gitwatchd") + return out == "active" +} + +func autostartOn() int { + if !systemctlPresent() { + warn("systemd not found: autostart needs a systemd user session.\n" + + " run the daemon manually instead: gitwatchd start") + return 1 + } + exe, err := os.Executable() + if err != nil { + warn("cannot resolve the gitwatchd binary path: " + err.Error()) + return 1 + } + exe, _ = filepath.EvalSymlinks(exe) + unit := fmt.Sprintf(`[Unit] +Description=gitwatchd: watch git repos and auto-commit changes + +[Service] +ExecStart=%s daemon +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +`, exe) + if err := os.MkdirAll(filepath.Dir(unitPath()), 0o755); err != nil { + warn("cannot create " + filepath.Dir(unitPath()) + ": " + err.Error()) + return 1 + } + if err := os.WriteFile(unitPath(), []byte(unit), 0o644); err != nil { + warn("cannot write " + unitPath() + ": " + err.Error()) + return 1 + } + systemctlUser("daemon-reload") + // A directly spawned daemon holds the single-instance lock and would + // make the unit fail; hand it over to systemd. + if isDaemonRunning() && !unitActive() { + if pid := daemonPid(); pid > 0 { + syscall.Kill(pid, syscall.SIGTERM) + for i := 0; i < 50 && isDaemonRunning(); i++ { + time.Sleep(100 * time.Millisecond) + } + } + } + if code, out := systemctlUser("enable", "--now", "gitwatchd"); code != 0 { + warn("systemctl --user enable --now gitwatchd failed: " + out) + return 1 + } + // Lingering keeps the user manager (and the daemon) alive without an + // open session: headless boots, logged-out laptops. + if code, out := runCommand("loginctl", []string{"enable-linger", os.Getenv("USER")}, ""); code != 0 { + fmt.Println("✓ autostart: on (systemd user unit enabled)") + fmt.Println(" note: loginctl enable-linger failed (" + strings.TrimSpace(out) + ")") + fmt.Println(" without lingering the daemon stops when you log out") + return 0 + } + fmt.Println("✓ autostart: on (systemd user unit enabled, survives logout and reboot)") + return 0 +} + +func autostartOff() int { + if !systemctlPresent() { + warn("systemd not found: nothing to turn off (autostart was never installed)") + return 1 + } + if !unitInstalled() { + fmt.Println("autostart: already off") + return 0 + } + systemctlUser("disable", "--now", "gitwatchd") + os.Remove(unitPath()) + systemctlUser("daemon-reload") + fmt.Println("✓ autostart: off") + return 0 +} + +func autostartStatus() int { + if !systemctlPresent() { + fmt.Println("autostart: unavailable (systemd not found); run the daemon with: gitwatchd start") + return 0 + } + if !unitInstalled() { + fmt.Println("autostart: off") + return 0 + } + _, enabled := systemctlUser("is-enabled", "gitwatchd") + state := "on" + if enabled != "enabled" { + state = "installed but " + enabled + } + if unitActive() { + fmt.Printf("autostart: %s (daemon running)\n", state) + } else { + fmt.Printf("autostart: %s (daemon not running)\n", state) + } + return 0 +} diff --git a/linux/watcher.go b/linux/watcher.go new file mode 100644 index 0000000..9389073 --- /dev/null +++ b/linux/watcher.go @@ -0,0 +1,313 @@ +package main + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + "unsafe" +) + +// Recursive inotify watcher for one repo, with a debounce (gitwatch's -s) and +// .git-churn filtering so our own commits don't retrigger the watcher. Also +// honors gitwatch's -x exclude patterns. +// +// On top of the gitwatch cycle it keeps an additive reliability layer: per-repo +// error state and automatic push retries with backoff. The layer never changes +// what a commit cycle does; it only re-runs the push stage of one that failed. + +// 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 + +type repoError struct { + outcome Outcome + lastAttempt time.Time + attempts int // consecutive failures + nextRetry *time.Time // nil: no auto retry, waits for a change +} + +type repoWatcher struct { + spec *RepoSpec + + fd int + wds map[int]string // watch descriptor -> directory path + mu sync.Mutex // guards wds and watchErr + + events chan struct{} + flush chan struct{} + stop chan struct{} + done chan struct{} + + lastError *repoError + watchErr string // e.g. the inotify watch limit; surfaced, never fatal + + // Called after every cycle with the repo path and its error state + // (nil while healthy); the daemon publishes it for `status`. + onState func(path string, status *RepoStatus) + logf func(format string, args ...any) +} + +func newRepoWatcher(spec *RepoSpec, onState 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, + fd: fd, + wds: map[int]string{}, + events: make(chan struct{}, 64), + flush: make(chan struct{}, 1), + stop: make(chan struct{}), + done: make(chan struct{}), + onState: onState, + 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.addWatchTracked(filepath.Dir(spec.Path)) + } else { + w.addRecursive(spec.Path) + } + go w.readLoop() + go w.run() + 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) addWatchTracked(dir string) { + wd, err := syscall.InotifyAddWatch(w.fd, dir, watchMask) + if err != nil { + w.addWatchError(dir, err) + return + } + w.mu.Lock() + w.wds[wd] = dir + w.mu.Unlock() +} + +func (w *repoWatcher) addWatchError(dir string, err error) { + w.mu.Lock() + defer w.mu.Unlock() + if err == syscall.ENOSPC { + w.watchErr = "inotify watch limit reached: raise fs.inotify.max_user_watches " + + "(sudo sysctl fs.inotify.max_user_watches=524288)" + } else if !os.IsNotExist(err) { + w.watchErr = "watch failed for " + dir + ": " + err.Error() + } +} + +func (w *repoWatcher) addRecursive(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.addWatchTracked(path) + return nil + }) +} + +func (w *repoWatcher) readLoop() { + buf := make([]byte, 64*1024) + for { + n, err := syscall.Read(w.fd, buf) + if err != nil || n <= 0 { + return // fd closed by Stop + } + offset := 0 + for offset+syscall.SizeofInotifyEvent <= n { + raw := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset])) + name := "" + if raw.Len > 0 { + b := buf[offset+syscall.SizeofInotifyEvent : offset+syscall.SizeofInotifyEvent+int(raw.Len)] + name = string(bytes.TrimRight(b, "\x00")) + } + offset += syscall.SizeofInotifyEvent + int(raw.Len) + w.handleEvent(int(raw.Wd), raw.Mask, name) + } + } +} + +func (w *repoWatcher) handleEvent(wd int, mask uint32, name string) { + if mask&syscall.IN_Q_OVERFLOW != 0 { + w.signal() // events were dropped; a cycle will catch whatever changed + return + } + w.mu.Lock() + dir, known := w.wds[wd] + if mask&syscall.IN_IGNORED != 0 { + delete(w.wds, wd) + } + w.mu.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 + } + w.signal() + 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.addRecursive(path) // a new subtree starts being watched immediately + } + w.signal() +} + +func (w *repoWatcher) signal() { + select { + case w.events <- struct{}{}: + default: + } +} + +// Run one cycle soon regardless of file events (-f at start, resume catch-up). +func (w *repoWatcher) flushNow() { + select { + case w.flush <- struct{}{}: + default: + } +} + +func (w *repoWatcher) run() { + defer close(w.done) + 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.events: + // 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.flush: + w.report(autoCommit(w.spec), retry) + case <-retry.C: + if w.lastError != nil { + w.report(push(w.spec), retry) + } + case <-w.stop: + debounce.Stop() + retry.Stop() + return + } + } +} + +// 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.publish() + w.logOutcome(outcome) +} + +func (w *repoWatcher) publish() { + 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 err := w.watchError(); err != "" { + status = &RepoStatus{ErrorLabel: "watch failing", Detail: err, + Attempts: 1, LastAttempt: time.Now().Unix()} + } + w.onState(w.spec.Path, status) +} + +func (w *repoWatcher) watchError() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.watchErr +} + +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.stop) + syscall.Close(w.fd) + <-w.done +} diff --git a/linux/watcher_test.go b/linux/watcher_test.go new file mode 100644 index 0000000..155252e --- /dev/null +++ b/linux/watcher_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// Live inotify tests: real filesystem events driving real commits, with a +// short settle so the suite stays fast. + +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.addWatchError("/some/dir", syscall.ENOSPC) + w.publish() + 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) + } +} From 6689afd21b97195b62f166a40d33479c5d1ec539 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Tue, 28 Jul 2026 14:28:26 +0000 Subject: [PATCH 02/19] Add the Linux install path 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 --- linux/Makefile | 24 +++++++++++++++++++++--- linux/install.sh | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100755 linux/install.sh diff --git a/linux/Makefile b/linux/Makefile index 81d26dd..5904399 100644 --- a/linux/Makefile +++ b/linux/Makefile @@ -1,20 +1,38 @@ # 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 (install.sh does the work) # make clean remove build artifacts GOFLAGS := -trimpath -ldflags "-s -w" +BUILD := build -.PHONY: all build test clean +# Where install.sh puts the binary; override as `make install DEST=/usr/local/bin`. +DEST ?= + +.PHONY: all build test install uninstall clean all: build build: - CGO_ENABLED=0 go build $(GOFLAGS) -o build/gitwatchd . + CGO_ENABLED=0 go build $(GOFLAGS) -o $(BUILD)/gitwatchd . test: go vet ./... go test ./... +install: build + @./install.sh $(DEST) + +uninstall: + @gitwatchd autostart off >/dev/null 2>&1 || true + @gitwatchd stop >/dev/null 2>&1 || true + @for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + [ -f "$$d/gitwatchd" ] && rm -f "$$d/gitwatchd" && echo " removed $$d/gitwatchd"; \ + done; true + @rm -rf "$$HOME/.local/state/gitwatchd" + @echo "✓ uninstalled: binary, systemd unit and daemon state" + @echo " (config at ~/.gitwatchd kept: 'rm ~/.gitwatchd' to reset watched repos too)" + clean: - rm -rf build + rm -rf $(BUILD) diff --git a/linux/install.sh b/linux/install.sh new file mode 100755 index 0000000..5fa018e --- /dev/null +++ b/linux/install.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Install the gitwatchd binary for the current user (default: ~/.local/bin). +# Usage: ./install.sh [destination-dir] +set -eu + +here=$(CDPATH= cd "$(dirname "$0")" && pwd) +bin="$here/build/gitwatchd" + +# `make install` has already built this; building here too keeps the script +# usable on its own. +if [ ! -x "$bin" ]; then + if command -v go >/dev/null 2>&1; then + echo "building gitwatchd..." + (cd "$here" && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$bin" .) + else + echo "error: nothing built at $bin and no Go toolchain to build it" >&2 + exit 1 + fi +fi + +dest="${1:-$HOME/.local/bin}" +mkdir -p "$dest" +install -m 0755 "$bin" "$dest/gitwatchd" +echo "installed $dest/gitwatchd" + +case ":$PATH:" in + *":$dest:"*) ;; + *) echo "note: $dest is not on your PATH; add it to your shell profile" ;; +esac + +echo "" +echo "next steps:" +echo " gitwatchd watch a repo" +echo " gitwatchd autostart on run at boot (systemd user unit)" +echo " gitwatchd status see everything watched" From 5faedb3fa242e768adb7e35070dff4a88d4ea09d Mon Sep 17 00:00:00 2001 From: Will Howard Date: Tue, 28 Jul 2026 15:29:08 +0000 Subject: [PATCH 03/19] Consolidate the Linux implementation into three files 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 --- linux/cli.go | 620 +++++++++++++++++++++++- linux/cli_test.go | 354 ++++++++++++++ linux/config.go | 253 ---------- linux/config_test.go | 77 --- linux/daemon.go | 399 ++++++++++++++++ linux/daemon_test.go | 160 ++++++- linux/flagcontract_test.go | 163 ------- linux/formatting_test.go | 133 ------ linux/{git.go => git_driver.go} | 15 +- linux/git_driver_test.go | 820 ++++++++++++++++++++++++++++++++ linux/gitengine_test.go | 371 --------------- linux/helpers_test.go | 198 -------- linux/main.go | 7 - linux/parity_test.go | 316 ------------ linux/repospec.go | 188 -------- linux/state.go | 84 ---- linux/statusformat.go | 102 ---- linux/systemd.go | 134 ------ linux/watcher.go | 313 ------------ linux/watcher_test.go | 126 ----- 20 files changed, 2350 insertions(+), 2483 deletions(-) delete mode 100644 linux/config.go delete mode 100644 linux/config_test.go delete mode 100644 linux/flagcontract_test.go delete mode 100644 linux/formatting_test.go rename linux/{git.go => git_driver.go} (95%) create mode 100644 linux/git_driver_test.go delete mode 100644 linux/gitengine_test.go delete mode 100644 linux/helpers_test.go delete mode 100644 linux/main.go delete mode 100644 linux/parity_test.go delete mode 100644 linux/repospec.go delete mode 100644 linux/state.go delete mode 100644 linux/statusformat.go delete mode 100644 linux/systemd.go delete mode 100644 linux/watcher.go delete mode 100644 linux/watcher_test.go diff --git a/linux/cli.go b/linux/cli.go index 9547e45..b7d6038 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -2,18 +2,18 @@ package main import ( "fmt" + "math" "os" "os/exec" + "path/filepath" + "regexp" + "strconv" "strings" "syscall" "time" + "unicode" ) -// The `gitwatchd` command-line client. Writes the shared config file (so it -// works even when the daemon is down) and relies on the running daemon's live -// reload. `add` accepts gitwatch's own flags verbatim, so gitwatch users need -// no relearning. - // 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" @@ -21,6 +21,70 @@ const version = "0.2.0" // Tests set this false so CLI calls don't spawn the real daemon. var spawnsDaemon = true +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 own flag vocabulary so +// that gitwatch users read our config/CLI with zero translation. +// +// gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg] +// [-x pattern] [-M] [-g gitdir] [-e events] +// +// Each config line is exactly the argument string you'd pass to gitwatch. +type RepoSpec struct { + Path string + Settle float64 // -s debounce seconds + DateFormat string // -d + Remote string // -r + Branch string // -b + Rebase bool // -R pull --rebase before push + Message string // -m (%d -> date) + Exclude string // -x regex; last one wins, as upstream + NoMergeCommit bool // -M + CommitOnStart bool // -f commit pending changes when watching starts + GitDir string // -g --git-dir + Paused bool // --paused (gitwatchd extension, not gitwatch: + // config-level so a pause survives restarts) + 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) @@ -56,7 +120,6 @@ func cliRun(args []string) int { case "daemon": return runDaemon() default: - // Bare form: `gitwatchd [flags] `: implicit add. return cliAdd(args) } } @@ -359,8 +422,6 @@ func cliStop() int { return 0 } -// Detached spawn for systems without systemd: its own session (setsid), output -// to a log file so the terminal can close. func spawnDaemon() error { exe, err := os.Executable() if err != nil { @@ -406,6 +467,549 @@ 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, needle string) bool { + return spec.Path == expandTilde(needle) || spec.Name() == needle || spec.Path == needle +} + +func configRemove(needle 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, needle) { + 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 `needle` (by full +// path or repo name, like remove). Pause lives in the config, not daemon +// state, so it survives daemon and machine restarts. Returns the number +// of lines changed. +func configSetPaused(needle 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, needle) { + 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 +} + +// The pure rewrite behind configSetPaused: if `line` watches `path` and its +// paused state differs, return the line with --paused added (in front) +// or removed; else ok=false for "leave this line alone". +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 +} + +// The tokenizer has no escape syntax: a value with both quote kinds +// cannot round-trip. +func quoteIfNeeded(s string) string { + if strings.Contains(s, `"`) && !strings.Contains(s, "'") { + return "'" + s + "'" + } + if strings.Contains(s, " ") || strings.Contains(s, `"`) || strings.Contains(s, "'") { + return `"` + s + `"` + } + return s +} + +// Split a config line into arguments on whitespace, respecting simple single +// or double quotes so that `-m "two words"` stays a single argument. +func tokenize(line string) []string { + var args []string + var current strings.Builder + var openQuote rune // the quote char we're inside, 0 if none + + finishArg := func() { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + } + + for _, ch := range line { + switch { + case openQuote != 0: + if ch == openQuote { + openQuote = 0 + } else { + current.WriteRune(ch) + } + case ch == '"' || ch == '\'': + openQuote = ch + case unicode.IsSpace(ch): + finishArg() + default: + current.WriteRune(ch) + } + } + finishArg() + return args +} + +// Parse a gitwatch-style argument list into a RepoSpec. +// Returns nil + an error message if there's no valid target. +func parseRepoSpec(args []string) (*RepoSpec, string) { + spec := &RepoSpec{ + Settle: 2, + DateFormat: "+%Y-%m-%d %H:%M:%S", + Message: "gitwatchd auto-commit (%d)", + } + target := "" + haveTarget := false + i := 0 + next := func() (string, bool) { + i++ + if i < len(args) { + return args[i], true + } + return "", false + } + + for i < len(args) { + a := args[i] + switch a { + case "-s": + v, ok := next() + d, err := strconv.ParseFloat(v, 64) + if !ok || err != nil || d < 0 { + return nil, "-s needs a number of seconds, 0 or more" + } + spec.Settle = d + case "-d": + if v, ok := next(); ok { + spec.DateFormat = v + } + case "-r", "-p": // -p: upstream's alias of -r + if v, ok := next(); ok { + spec.Remote = v + } + case "-b": + if v, ok := next(); ok { + spec.Branch = v + } + case "-R": + spec.Rebase = true + case "-m": + if v, ok := next(); ok { + spec.Message = v + } + case "-x": + v, ok := next() + if !ok { + return nil, "-x needs a valid regular expression" + } + if _, err := regexp.Compile(v); err != nil { + return nil, "-x needs a valid regular expression" + } + spec.Exclude = v + case "-M": + spec.NoMergeCommit = true + case "-f": + spec.CommitOnStart = true + case "-g": + if v, ok := next(); ok { + spec.GitDir = v + } + case "-e": + next() // inotify events: accepted, no-op (we watch a fixed gitwatch-like set) + 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 +} + +// Pure string formatting for `gitwatchd status`. Same rows and strings as the +// macOS menu, so the habit transfers between machines unchanged. + +// 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) +} + +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))) +} + +// Autostart = a systemd user unit, the standard way for a per-user daemon to +// survive reboots and headless boots (with lingering). When systemd is absent +// we say so and do nothing: no shell-profile edits, ever. + +func unitPath() string { + return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") +} + +func systemctlPresent() bool { + _, err := exec.LookPath("systemctl") + return err == nil +} + +func unitInstalled() bool { + _, err := os.Stat(unitPath()) + return err == nil +} + +func systemctlUser(args ...string) (int, string) { + return runCommand("systemctl", append([]string{"--user"}, args...), "") +} + +func unitActive() bool { + _, out := systemctlUser("is-active", "gitwatchd") + return out == "active" +} + +func autostartOn() int { + if !systemctlPresent() { + warn("systemd not found: autostart needs a systemd user session.\n" + + " run the daemon manually instead: gitwatchd start") + return 1 + } + exe, err := os.Executable() + if err != nil { + warn("cannot resolve the gitwatchd binary path: " + err.Error()) + return 1 + } + exe, _ = filepath.EvalSymlinks(exe) + unit := fmt.Sprintf(`[Unit] +Description=gitwatchd: watch git repos and auto-commit changes + +[Service] +ExecStart=%s daemon +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +`, exe) + if err := os.MkdirAll(filepath.Dir(unitPath()), 0o755); err != nil { + warn("cannot create " + filepath.Dir(unitPath()) + ": " + err.Error()) + return 1 + } + if err := os.WriteFile(unitPath(), []byte(unit), 0o644); err != nil { + warn("cannot write " + unitPath() + ": " + err.Error()) + return 1 + } + systemctlUser("daemon-reload") + // A directly spawned daemon holds the single-instance lock and would + // make the unit fail; hand it over to systemd. + if isDaemonRunning() && !unitActive() { + if pid := daemonPid(); pid > 0 { + syscall.Kill(pid, syscall.SIGTERM) + for i := 0; i < 50 && isDaemonRunning(); i++ { + time.Sleep(100 * time.Millisecond) + } + } + } + if code, out := systemctlUser("enable", "--now", "gitwatchd"); code != 0 { + warn("systemctl --user enable --now gitwatchd failed: " + out) + return 1 + } + // Lingering keeps the user manager (and the daemon) alive without an + // open session: headless boots, logged-out laptops. + if code, out := runCommand("loginctl", []string{"enable-linger", os.Getenv("USER")}, ""); code != 0 { + fmt.Println("✓ autostart: on (systemd user unit enabled)") + fmt.Println(" note: loginctl enable-linger failed (" + strings.TrimSpace(out) + ")") + fmt.Println(" without lingering the daemon stops when you log out") + return 0 + } + fmt.Println("✓ autostart: on (systemd user unit enabled, survives logout and reboot)") + return 0 +} + +func autostartOff() int { + if !systemctlPresent() { + warn("systemd not found: nothing to turn off (autostart was never installed)") + return 1 + } + if !unitInstalled() { + fmt.Println("autostart: already off") + return 0 + } + systemctlUser("disable", "--now", "gitwatchd") + os.Remove(unitPath()) + systemctlUser("daemon-reload") + fmt.Println("✓ autostart: off") + return 0 +} + +func autostartStatus() int { + if !systemctlPresent() { + fmt.Println("autostart: unavailable (systemd not found); run the daemon with: gitwatchd start") + return 0 + } + if !unitInstalled() { + fmt.Println("autostart: off") + return 0 + } + _, enabled := systemctlUser("is-enabled", "gitwatchd") + state := "on" + if enabled != "enabled" { + state = "installed but " + enabled + } + if unitActive() { + fmt.Printf("autostart: %s (daemon running)\n", state) + } else { + fmt.Printf("autostart: %s (daemon not running)\n", state) + } + return 0 +} + const usageText = `gitwatchd - daemon that watches git repos and auto-commits changes USAGE diff --git a/linux/cli_test.go b/linux/cli_test.go index 969d4c5..89594f7 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // CLI contract tests run the real cliRun against a scratch config directory @@ -232,3 +233,356 @@ func TestBrokenConfigEntriesSurfaceInLoadAndStatus(t *testing.T) { 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]) + } + } +} + +// The help text states flag defaults and exclusion behaviour as facts; +// these tests pin them so the help can't silently drift from the code. + +func TestDefaultsForBarePath(t *testing.T) { + spec, errMsg := parseRepoSpec([]string{"/tmp/x"}) + if spec == nil { + t.Fatal(errMsg) + } + if spec.Settle != 2 { // -s "Default: 2" + t.Errorf("settle = %v", spec.Settle) + } + if spec.Remote != "" { // -r "Default: no push" + t.Errorf("remote = %q", spec.Remote) + } + if spec.Message != "gitwatchd auto-commit (%d)" { + t.Errorf("message = %q", spec.Message) + } + if spec.DateFormat != "+%Y-%m-%d %H:%M:%S" { // upstream's exact default, leading + included + t.Errorf("dateFormat = %q", spec.DateFormat) + } + if spec.Branch != "" || spec.Rebase || spec.Exclude != "" || spec.NoMergeCommit || spec.Paused { + t.Errorf("unexpected non-default: %+v", spec) + } + if spec.CommitOnStart { + t.Error("-f is opt-in: a deliberate manual state is not flushed") + } +} + +func TestPIsAnAliasOfR(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-p", "origin", "/tmp/x"}) + if spec == nil || spec.Remote != "origin" { + t.Errorf("got %+v", spec) + } +} + +func TestSettleRejectsBadValues(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); spec != nil { + t.Error("-s -1 should be rejected") + } + if spec, _ := parseRepoSpec([]string{"-s", "soon", "/tmp/x"}); spec != nil { + t.Error("-s soon should be rejected") + } + if _, msg := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); msg != "-s needs a number of seconds, 0 or more" { + t.Errorf("wrong message: %q", msg) + } + spec, _ := parseRepoSpec([]string{"-s", "0", "/tmp/x"}) + if spec == nil || spec.Settle != 0 { + t.Error("-s 0 is valid") + } +} + +func TestExcludeMatchesAnywhereInPath(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/deep/dir/debug.log") { + t.Error("should match a nested .log file") + } + if spec.Excludes("/tmp/x/log.txt") { + t.Error("should not match log.txt") + } +} + +func TestExcludeDirectoryPattern(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", "build/", "/tmp/x"}) + if !spec.Excludes("/tmp/x/build/out.o") { + t.Error("should exclude the build subtree") + } + if spec.Excludes("/tmp/x/src/out.o") { + t.Error("should not exclude src") + } +} + +func TestLastExcludeWins(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "-x", `\.tmp$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/b.tmp") { + t.Error("the last -x should apply") + } + if spec.Excludes("/tmp/x/a.log") { + t.Error("the first -x should be forgotten, as upstream") + } +} + +func TestInvalidExcludeIsAParseError(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-x", "*.log", "/tmp/x"}); spec != nil { + t.Error("an invalid regex must be a parse error, not a silent no-op") + } +} + +func TestNoExcludeByDefault(t *testing.T) { + spec, _ := parseRepoSpec([]string{"/tmp/x"}) + if spec.Excludes("/tmp/x/anything.log") { + t.Error("nothing is excluded without -x") + } +} + +// The help is the contract; these hold it to the implementation. The +// canonical lists below ARE the documented surface: adding a flag or command +// to only one side of the contract fails one of these tests. + +var contractValueFlags = map[string]string{ + "-s": "2", "-r": "origin", "-b": "main", "-m": "msg", + "-d": "+%Y", "-x": `\.log$`, "-g": "/tmp/gd", +} + +var contractBoolFlags = []string{"-R", "-M", "-f", "--paused"} +var contractCommands = []string{"add", "rm", "pause", "resume", "status", + "start", "stop", "autostart", "config", "help", "version"} + +func TestDocumentedFlagsParse(t *testing.T) { + for flag, value := range contractValueFlags { + if spec, msg := parseRepoSpec([]string{flag, value, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } + for _, flag := range contractBoolFlags { + if spec, msg := parseRepoSpec([]string{flag, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } +} + +func TestContractAppearsInHelp(t *testing.T) { + for flag := range contractValueFlags { + if !strings.Contains(usageText, flag+" <") { + t.Errorf("help is missing %s with its ", flag) + } + } + for _, flag := range contractBoolFlags { + if !strings.Contains(usageText, flag) { + t.Errorf("help is missing %s", flag) + } + } + for _, command := range contractCommands { + if !strings.Contains(usageText, command) { + t.Errorf("help is missing the %s command", command) + } + } +} + +func TestNoPhantomFlagsInHelp(t *testing.T) { + known := map[string]bool{} + for flag := range contractValueFlags { + known[flag] = true + } + for _, flag := range contractBoolFlags { + known[flag] = true + } + for _, line := range strings.Split(usageText, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "-") { + continue + } + flag := strings.Fields(line)[0] + if !known[flag] { + t.Errorf("help documents %s, which the contract list doesn't know", flag) + } + } +} + +// What `status` shows, asserted as exact strings: the same rows the macOS +// menu renders, so status reads identically across machines. + +func TestHealthyRowIsNameAndBranch(t *testing.T) { + if got := rowTitle("notes", "main", false, 0, ""); got != "notes · main" { + t.Errorf("got %q", got) + } +} + +func TestPendingEditsShowACount(t *testing.T) { + if got := rowTitle("notes", "main", false, 3, ""); got != "notes · main · 3 pending changes" { + t.Errorf("got %q", got) + } + if got := rowTitle("notes", "main", false, 1, ""); got != "notes · main · 1 pending change" { + t.Errorf("got %q", got) + } +} + +func TestErrorLabelFlagsTheRow(t *testing.T) { + if got := rowTitle("notes", "main", false, 0, "push failing"); got != "notes · main · ⚠ push failing" { + t.Errorf("got %q", got) + } +} + +func TestOutcomeLabels(t *testing.T) { + cases := map[OutcomeKind]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, 300 * time.Second, 300 * time.Second} + 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) + } + } +} + +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/config.go b/linux/config.go deleted file mode 100644 index e859e9c..0000000 --- a/linux/config.go +++ /dev/null @@ -1,253 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "unicode" -) - -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 -} - -// 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) -} - -// Non-comment, non-empty raw lines. -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 -} - -// Parsed specs (invalid lines are skipped; surface them via configLineErrors). -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 -} - -// Config lines that don't parse into a spec at all, with the reason. -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 -} - -// One pass over the config: the watchable specs, plus every entry that -// can't be watched (unparseable line, missing path, not a git repo). -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 -} - -// Append a repo line (raw gitwatch args). Creates the file if needed. -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) -} - -// True if `spec` is the repo the user means by `needle`: full path, -// tilde path, or folder name. -func configMatches(spec *RepoSpec, needle string) bool { - return spec.Path == expandTilde(needle) || spec.Name() == needle || spec.Path == needle -} - -// Remove matching lines; returns how many were removed. -func configRemove(needle 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, needle) { - 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 `needle` (by full -// path or repo name, like remove). Pause lives in the config, not daemon -// state, so it survives daemon and machine restarts. Returns the number -// of lines changed. -func configSetPaused(needle 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, needle) { - 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 -} - -// The pure rewrite behind configSetPaused: if `line` watches `path` and its -// paused state differs, return the line with --paused added (in front) -// or removed; else ok=false for "leave this line alone". -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 -} - -// The tokenizer has no escape syntax: a value with both quote kinds -// cannot round-trip. -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: - // Inside quotes: the matching quote closes; everything else is literal. - if ch == openQuote { - openQuote = 0 - } else { - current.WriteRune(ch) - } - case ch == '"' || ch == '\'': - openQuote = ch // start a quoted section - case unicode.IsSpace(ch): - finishArg() // whitespace separates arguments - default: - current.WriteRune(ch) - } - } - finishArg() - return args -} diff --git a/linux/config_test.go b/linux/config_test.go deleted file mode 100644 index 5b8dcab..0000000 --- a/linux/config_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "testing" -) - -// Pause is config-level state: a --paused token on the repo's line, so a -// paused repo stays paused across daemon and machine restarts. These tests -// pin the parsing and the pure line-rewrite pause/resume use. - -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]) - } - } -} diff --git a/linux/daemon.go b/linux/daemon.go index 8fff42b..ae941d0 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -1,16 +1,75 @@ package main import ( + "bytes" + "encoding/json" "fmt" + "io/fs" + "math" "os" "os/signal" "path/filepath" "strconv" "strings" + "sync" "syscall" "time" + "unsafe" ) +// 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. Stored as one JSON file rewritten +// atomically; the daemon is the only writer. +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"` +} + +// Recursive inotify watcher for one repo, with a debounce (gitwatch's -s) and +// .git-churn filtering so our own commits don't retrigger the watcher. Also +// honors gitwatch's -x exclude patterns. +// +// On top of the gitwatch cycle it keeps an additive reliability layer: per-repo +// error state and automatic push retries with backoff. The layer never changes +// what a commit cycle does; it only re-runs the push stage of one that failed. + +type repoError struct { + outcome Outcome + lastAttempt time.Time + attempts int // consecutive failures + nextRetry *time.Time // nil: no auto retry, waits for a change +} + +type repoWatcher struct { + spec *RepoSpec + + fd int + wds map[int]string // watch descriptor -> directory path + mu sync.Mutex // guards wds and watchErr + + events chan struct{} + flush chan struct{} + stop chan struct{} + done chan struct{} + + lastError *repoError + watchErr string // e.g. the inotify watch limit; surfaced, never fatal + + // Called after every cycle with the repo path and its error state + // (nil while healthy); the daemon publishes it for `status`. + onState func(path string, status *RepoStatus) + logf func(format string, args ...any) +} + // The headless daemon: one process, one flock-guarded pidfile, a watcher per // configured repo, and a live reload whenever ~/.gitwatchd changes. Under // systemd its stdout/stderr go to the journal; that is the log. @@ -203,3 +262,343 @@ func daemonPid() int { pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) return pid } + +func newRepoWatcher(spec *RepoSpec, onState 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, + fd: fd, + wds: map[int]string{}, + events: make(chan struct{}, 64), + flush: make(chan struct{}, 1), + stop: make(chan struct{}), + done: make(chan struct{}), + onState: onState, + 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.addWatchTracked(filepath.Dir(spec.Path)) + } else { + w.addRecursive(spec.Path) + } + go w.readLoop() + go w.run() + 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) addWatchTracked(dir string) { + wd, err := syscall.InotifyAddWatch(w.fd, dir, watchMask) + if err != nil { + w.addWatchError(dir, err) + return + } + w.mu.Lock() + w.wds[wd] = dir + w.mu.Unlock() +} + +func (w *repoWatcher) addWatchError(dir string, err error) { + w.mu.Lock() + defer w.mu.Unlock() + if err == syscall.ENOSPC { + w.watchErr = "inotify watch limit reached: raise fs.inotify.max_user_watches " + + "(sudo sysctl fs.inotify.max_user_watches=524288)" + } else if !os.IsNotExist(err) { + w.watchErr = "watch failed for " + dir + ": " + err.Error() + } +} + +func (w *repoWatcher) addRecursive(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.addWatchTracked(path) + return nil + }) +} + +func (w *repoWatcher) readLoop() { + buf := make([]byte, 64*1024) + for { + n, err := syscall.Read(w.fd, buf) + if err != nil || n <= 0 { + return // fd closed by Stop + } + offset := 0 + for offset+syscall.SizeofInotifyEvent <= n { + raw := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset])) + name := "" + if raw.Len > 0 { + b := buf[offset+syscall.SizeofInotifyEvent : offset+syscall.SizeofInotifyEvent+int(raw.Len)] + name = string(bytes.TrimRight(b, "\x00")) + } + offset += syscall.SizeofInotifyEvent + int(raw.Len) + w.handleEvent(int(raw.Wd), raw.Mask, name) + } + } +} + +func (w *repoWatcher) handleEvent(wd int, mask uint32, name string) { + if mask&syscall.IN_Q_OVERFLOW != 0 { + w.signal() // events were dropped; a cycle will catch whatever changed + return + } + w.mu.Lock() + dir, known := w.wds[wd] + if mask&syscall.IN_IGNORED != 0 { + delete(w.wds, wd) + } + w.mu.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 + } + w.signal() + 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.addRecursive(path) + } + w.signal() +} + +func (w *repoWatcher) signal() { + select { + case w.events <- struct{}{}: + default: + } +} + +// Run one cycle soon regardless of file events (-f at start, resume catch-up). +func (w *repoWatcher) flushNow() { + select { + case w.flush <- struct{}{}: + default: + } +} + +func (w *repoWatcher) run() { + defer close(w.done) + 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.events: + // 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.flush: + w.report(autoCommit(w.spec), retry) + case <-retry.C: + if w.lastError != nil { + w.report(push(w.spec), retry) + } + case <-w.stop: + debounce.Stop() + retry.Stop() + return + } + } +} + +// Push retry backoff: 30s doubling to a 5 minute cap. +const ( + backoffFirst = 30 * time.Second + backoffCap = 300 * time.Second +) + +func backoffDelay(afterFailures int) time.Duration { + if afterFailures <= 1 { + return backoffFirst + } + d := time.Duration(float64(backoffFirst) * math.Pow(2, float64(afterFailures-1))) + if d > backoffCap { + return backoffCap + } + return 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.publish() + w.logOutcome(outcome) +} + +func (w *repoWatcher) publish() { + 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 err := w.watchError(); err != "" { + status = &RepoStatus{ErrorLabel: "watch failing", Detail: err, + Attempts: 1, LastAttempt: time.Now().Unix()} + } + w.onState(w.spec.Path, status) +} + +func (w *repoWatcher) watchError() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.watchErr +} + +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.stop) + syscall.Close(w.fd) + <-w.done +} + +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 pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } +func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } + +var stateMu sync.Mutex + +func stateErrors() map[string]RepoStatus { + raw, err := os.ReadFile(statePath()) + if err != nil { + return map[string]RepoStatus{} + } + var out map[string]RepoStatus + if json.Unmarshal(raw, &out) != nil || out == nil { + return map[string]RepoStatus{} + } + return out +} + +func stateSet(repoPath string, status *RepoStatus) { + stateMu.Lock() + defer stateMu.Unlock() + errs := stateErrors() + if status == nil { + delete(errs, repoPath) + } else { + errs[repoPath] = *status + } + stateWrite(errs) +} + +func statePrune(keep map[string]bool) { + stateMu.Lock() + defer stateMu.Unlock() + errs := stateErrors() + for path := range errs { + if !keep[path] { + delete(errs, path) + } + } + stateWrite(errs) +} + +func stateWrite(errs map[string]RepoStatus) { + os.MkdirAll(stateDir(), 0o755) + raw, err := json.Marshal(errs) + if err != nil { + return + } + tmp := statePath() + ".tmp" + if os.WriteFile(tmp, raw, 0o644) == nil { + os.Rename(tmp, statePath()) + } +} diff --git a/linux/daemon_test.go b/linux/daemon_test.go index 30841fa..07529d2 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -10,9 +11,46 @@ import ( "time" ) -// End-to-end through the real binary: config written by the CLI, daemon -// started detached, inotify driving commits, live config reload, and a clean -// stop. One flow, because this is exactly the session a user has. +// The built gitwatchd binary, for tests that exercise the real daemon. +var testBinary string + +func TestMain(m *testing.M) { + 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) { + cmd := exec.Command(testBinary, 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 == "" { @@ -53,15 +91,12 @@ func TestDaemonEndToEnd(t *testing.T) { t.Errorf("status should show the watched row:\n%s", out) } - // Live reload: adding a second repo while the daemon runs starts - // watching it without a restart. 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") }) - // Pause stops commits; resume catches up on what piled up meanwhile. if code, out := runCLI(env, "pause", repo.path); code != 0 { t.Fatalf("pause failed: %s", out) } @@ -161,3 +196,116 @@ func killDaemonIfRunning(home string) { 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.addWatchError("/some/dir", syscall.ENOSPC) + w.publish() + 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) + } +} diff --git a/linux/flagcontract_test.go b/linux/flagcontract_test.go deleted file mode 100644 index 1f83a8e..0000000 --- a/linux/flagcontract_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -// The help text states flag defaults and exclusion behaviour as facts; -// these tests pin them so the help can't silently drift from the code. - -func TestDefaultsForBarePath(t *testing.T) { - spec, errMsg := parseRepoSpec([]string{"/tmp/x"}) - if spec == nil { - t.Fatal(errMsg) - } - if spec.Settle != 2 { // -s "Default: 2" - t.Errorf("settle = %v", spec.Settle) - } - if spec.Remote != "" { // -r "Default: no push" - t.Errorf("remote = %q", spec.Remote) - } - if spec.Message != "gitwatchd auto-commit (%d)" { - t.Errorf("message = %q", spec.Message) - } - if spec.DateFormat != "+%Y-%m-%d %H:%M:%S" { // upstream's exact default, leading + included - t.Errorf("dateFormat = %q", spec.DateFormat) - } - if spec.Branch != "" || spec.Rebase || spec.Exclude != "" || spec.NoMergeCommit || spec.Paused { - t.Errorf("unexpected non-default: %+v", spec) - } - if spec.CommitOnStart { - t.Error("-f is opt-in: a deliberate manual state is not flushed") - } -} - -func TestPIsAnAliasOfR(t *testing.T) { - spec, _ := parseRepoSpec([]string{"-p", "origin", "/tmp/x"}) - if spec == nil || spec.Remote != "origin" { - t.Errorf("got %+v", spec) - } -} - -func TestSettleRejectsBadValues(t *testing.T) { - if spec, _ := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); spec != nil { - t.Error("-s -1 should be rejected") - } - if spec, _ := parseRepoSpec([]string{"-s", "soon", "/tmp/x"}); spec != nil { - t.Error("-s soon should be rejected") - } - if _, msg := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); msg != "-s needs a number of seconds, 0 or more" { - t.Errorf("wrong message: %q", msg) - } - spec, _ := parseRepoSpec([]string{"-s", "0", "/tmp/x"}) - if spec == nil || spec.Settle != 0 { - t.Error("-s 0 is valid") - } -} - -func TestExcludeMatchesAnywhereInPath(t *testing.T) { - spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "/tmp/x"}) - if !spec.Excludes("/tmp/x/deep/dir/debug.log") { - t.Error("should match a nested .log file") - } - if spec.Excludes("/tmp/x/log.txt") { - t.Error("should not match log.txt") - } -} - -func TestExcludeDirectoryPattern(t *testing.T) { - spec, _ := parseRepoSpec([]string{"-x", "build/", "/tmp/x"}) - if !spec.Excludes("/tmp/x/build/out.o") { - t.Error("should exclude the build subtree") - } - if spec.Excludes("/tmp/x/src/out.o") { - t.Error("should not exclude src") - } -} - -func TestLastExcludeWins(t *testing.T) { - spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "-x", `\.tmp$`, "/tmp/x"}) - if !spec.Excludes("/tmp/x/b.tmp") { - t.Error("the last -x should apply") - } - if spec.Excludes("/tmp/x/a.log") { - t.Error("the first -x should be forgotten, as upstream") - } -} - -func TestInvalidExcludeIsAParseError(t *testing.T) { - if spec, _ := parseRepoSpec([]string{"-x", "*.log", "/tmp/x"}); spec != nil { - t.Error("an invalid regex must be a parse error, not a silent no-op") - } -} - -func TestNoExcludeByDefault(t *testing.T) { - spec, _ := parseRepoSpec([]string{"/tmp/x"}) - if spec.Excludes("/tmp/x/anything.log") { - t.Error("nothing is excluded without -x") - } -} - -// The help is the contract; these hold it to the implementation. The -// canonical lists below ARE the documented surface: adding a flag or command -// to only one side of the contract fails one of these tests. - -var contractValueFlags = map[string]string{ - "-s": "2", "-r": "origin", "-b": "main", "-m": "msg", - "-d": "+%Y", "-x": `\.log$`, "-g": "/tmp/gd", -} -var contractBoolFlags = []string{"-R", "-M", "-f", "--paused"} -var contractCommands = []string{"add", "rm", "pause", "resume", "status", - "start", "stop", "autostart", "config", "help", "version"} - -func TestDocumentedFlagsParse(t *testing.T) { - for flag, value := range contractValueFlags { - if spec, msg := parseRepoSpec([]string{flag, value, "/tmp/x"}); spec == nil { - t.Errorf("%s is documented but doesn't parse: %s", flag, msg) - } - } - for _, flag := range contractBoolFlags { - if spec, msg := parseRepoSpec([]string{flag, "/tmp/x"}); spec == nil { - t.Errorf("%s is documented but doesn't parse: %s", flag, msg) - } - } -} - -func TestContractAppearsInHelp(t *testing.T) { - for flag := range contractValueFlags { - if !strings.Contains(usageText, flag+" <") { - t.Errorf("help is missing %s with its ", flag) - } - } - for _, flag := range contractBoolFlags { - if !strings.Contains(usageText, flag) { - t.Errorf("help is missing %s", flag) - } - } - for _, command := range contractCommands { - if !strings.Contains(usageText, command) { - t.Errorf("help is missing the %s command", command) - } - } -} - -func TestNoPhantomFlagsInHelp(t *testing.T) { - known := map[string]bool{} - for flag := range contractValueFlags { - known[flag] = true - } - for _, flag := range contractBoolFlags { - known[flag] = true - } - for _, line := range strings.Split(usageText, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "-") { - continue - } - flag := strings.Fields(line)[0] - if !known[flag] { - t.Errorf("help documents %s, which the contract list doesn't know", flag) - } - } -} diff --git a/linux/formatting_test.go b/linux/formatting_test.go deleted file mode 100644 index acfb48e..0000000 --- a/linux/formatting_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package main - -import ( - "testing" - "time" -) - -// 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[OutcomeKind]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, 300 * time.Second, 300 * time.Second} - 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) - } - } -} - -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/git.go b/linux/git_driver.go similarity index 95% rename from linux/git.go rename to linux/git_driver.go index d8ecda9..f20322b 100644 --- a/linux/git.go +++ b/linux/git_driver.go @@ -43,7 +43,6 @@ func (o Outcome) ErrorLabel() string { return "" } -// Run a program and capture stdout+stderr, trimmed. func runCommand(exe string, args []string, cwd string) (int, string) { cmd := exec.Command(exe, args...) if cwd != "" { @@ -99,7 +98,6 @@ func lastCommitSummary(dir string, gitDir string) string { return out } -// True if a state file/dir exists inside the repo's resolved .git dir. func gitStateExists(names []string, dir string, gitDir string) bool { code, top := gitRun([]string{"rev-parse", "--git-dir"}, dir, gitDir) if code != 0 { @@ -231,8 +229,6 @@ func pushArgs(remote string, spec *RepoSpec) []string { return []string{"push", remote, current + ":" + spec.Branch} } -// The most informative line of a failed command's output: the first -// error/fatal/rejection line if there is one, else the last non-empty line. func errorSummary(out string) string { var lines []string for _, l := range strings.Split(out, "\n") { @@ -252,3 +248,14 @@ func errorSummary(out string) string { } return "unknown git error" } + +// Render the current date exactly as upstream does: the -d value passes to +// date(1) verbatim (the user includes the leading +), and a format date(1) +// rejects yields an empty string. +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..0067a02 --- /dev/null +++ b/linux/git_driver_test.go @@ -0,0 +1,820 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +type testRepo struct { + t *testing.T + path string +} + +func newTestRepo(t *testing.T) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "repo")} + os.MkdirAll(r.path, 0o755) + r.git("init", "-q", "-b", "main") + r.configureUser() + return r +} + +// A second working copy of a remote (a colleague's machine). +func newCloneOf(t *testing.T, remote *bareRemote) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "clone")} + runCommand("git", []string{"clone", "-q", remote.path, r.path}, "") + r.configureUser() + return r +} + +func (r *testRepo) configureUser() { + r.git("config", "user.email", "tests@gitwatchd.local") + r.git("config", "user.name", "gitwatchd tests") + r.git("config", "commit.gpgsign", "false") +} + +// The spec `gitwatchd add ` would produce. +func (r *testRepo) spec(flags ...string) *RepoSpec { + r.t.Helper() + spec, errMsg := parseRepoSpec(append(flags, r.path)) + if spec == nil { + r.t.Fatalf("spec did not parse: %s", errMsg) + } + return spec +} + +func (r *testRepo) git(args ...string) string { + _, out := gitRun(args, r.path, "") + return out +} + +func (r *testRepo) write(file, contents string) { + r.t.Helper() + full := filepath.Join(r.path, file) + os.MkdirAll(filepath.Dir(full), 0o755) + if err := os.WriteFile(full, []byte(contents), 0o644); err != nil { + r.t.Fatal(err) + } +} + +func (r *testRepo) commitCount() int { + n, _ := strconv.Atoi(r.git("rev-list", "--count", "HEAD")) + return n +} + +func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%s") } + +func (r *testRepo) midMerge() bool { + _, err := os.Stat(filepath.Join(r.path, ".git", "MERGE_HEAD")) + return err == nil +} + +func (r *testRepo) midRebase() bool { + for _, d := range []string{"rebase-merge", "rebase-apply"} { + if _, err := os.Stat(filepath.Join(r.path, ".git", d)); err == nil { + return true + } + } + return false +} + +func (r *testRepo) addOrigin() *bareRemote { + remote := newBareRemote(r.t) + r.git("remote", "add", "origin", remote.path) + return remote +} + +func (r *testRepo) addOriginURL(url string) { + r.git("remote", "add", "origin", url) +} + +func (r *testRepo) setOriginURL(url string) { r.git("remote", "set-url", "origin", url) } + +func (r *testRepo) installFailingPreCommitHook(printing string) { + hook := filepath.Join(r.path, ".git", "hooks", "pre-commit") + os.WriteFile(hook, []byte("#!/bin/sh\necho '"+printing+"'\nexit 1\n"), 0o755) +} + +func (r *testRepo) commit(file, contents, message string) { + r.write(file, contents) + r.git("add", "-A") + r.git("commit", "-q", "-m", message) +} + +// Set main to track origin/main, as a cloned repo would. The branch-less +// `pull --rebase ` needs it. +func (r *testRepo) trackOrigin() { + r.git("fetch", "-q", "origin") + r.git("branch", "-q", "--set-upstream-to=origin/main", "main") +} + +// Stop this repo mid-merge on a real conflict. Plain git only, so tests +// never exercise the engine during their own setup. +func (r *testRepo) conflictedMerge() { + r.commit("f.txt", "base\n", "base") + r.git("checkout", "-q", "-b", "side") + r.commit("f.txt", "side\n", "side edit") + r.git("checkout", "-q", "main") + r.commit("f.txt", "main\n", "main edit") + r.git("merge", "side") // conflicts, leaving MERGE_HEAD behind +} + +type bareRemote struct { + path string +} + +func newBareRemote(t *testing.T) *bareRemote { + t.Helper() + b := &bareRemote{path: filepath.Join(t.TempDir(), "remote.git")} + runCommand("git", []string{"init", "-q", "--bare", "-b", "main", b.path}, "") + return b +} + +func (b *bareRemote) commitCount() int { + code, out := gitRun([]string{"rev-list", "--count", "main"}, b.path, "") + if code != 0 { + return 0 + } + n, _ := strconv.Atoi(out) + return n +} + +func (b *bareRemote) lastMessage() string { + _, out := gitRun([]string{"log", "-1", "--pretty=%s", "main"}, b.path, "") + return out +} + +// Engine tests drive autoCommit / push against real throwaway git repos, +// asserting both the reported outcome and the repo state left behind +// (the gitwatch parity contract: same commands, same end state). + +func TestCleanRepoDoesNothing(t *testing.T) { + repo := newTestRepo(t) + repo.write("seed.txt", "v1") + autoCommit(repo.spec()) // absorb the initial commit + if got := autoCommit(repo.spec()); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Error("no extra commit should appear") + } +} + +func TestChangesCommitLocallyWithoutRemote(t *testing.T) { + repo := newTestRepo(t) + repo.write("notes.txt", "hello") + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Errorf("commits = %d", repo.commitCount()) + } + if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { + t.Errorf("default message expected, got: %s", repo.lastMessage()) + } +} + +func TestCustomMessageAndDateExpansion(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved on %d", "-d", "+%Y")) + if !strings.HasPrefix(repo.lastMessage(), "saved on 2") { // "saved on 2026" + t.Errorf("got: %s", repo.lastMessage()) + } +} + +// Sharp corner, kept for upstream parity: -d goes to date(1) raw, so a +// format without a leading + splices an empty date. Git's commit-message +// cleanup then trims the trailing whitespace. +func TestSharpCornerRawDateFormatWithoutPlusSplicesEmptyDate(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "at %d", "-d", "%Y")) + if repo.lastMessage() != "at" { + t.Errorf("got %q, want %q", repo.lastMessage(), "at") + } +} + +func TestOnlyTheFirstDateTokenIsExpanded(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved %d then %d", "-d", "+%Y")) + got := repo.lastMessage() + if !strings.HasPrefix(got, "saved 2") || !strings.HasSuffix(got, " then %d") { + t.Errorf("upstream splices the date into the first %%d only, got: %s", got) + } +} + +func TestPushToRemote(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the remote should have the commit") + } +} + +func TestUnreachableRemoteReportsPushFailedButCommitSurvives(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "not-a-remote.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if got.Detail == "" { + t.Error("the git error is captured for status") + } + if repo.commitCount() != 1 { + t.Error("gitwatch parity: commit stays, only the push failed") + } +} + +func TestPushRetriesStrandedCommitAfterOutage(t *testing.T) { + repo := newTestRepo(t) + origin := newBareRemote(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "offline.git")) // remote "down" + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != PushFailed { + t.Fatalf("setup: expected the first push to fail, got %+v", got) + } + repo.setOriginURL(origin.path) // remote "back up" + if got := push(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the earlier commit reached the remote") + } +} + +func TestPullFailureWhileOfflineIsTransientNotConflict(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if repo.midRebase() { + t.Error("no rebase was ever started, so the retry loop may heal this") + } +} + +func TestIdempotentRetryStillReportsPushed(t *testing.T) { + repo := newTestRepo(t) + repo.addOrigin() + repo.write("a.txt", "1") + spec := repo.spec("-r", "origin", "-b", "main") + autoCommit(spec) + if got := push(spec); got.Kind != Pushed { // "Everything up-to-date" + t.Errorf("got %+v", got) + } +} + +func TestPushFormWithoutBranch(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-r", "origin", "/tmp/x"}) + got := pushArgs("origin", spec) + if strings.Join(got, " ") != "push origin" { + t.Errorf("got %v", got) + } +} + +func TestPushFormWithBranch(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "-b", "feature") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin feature:main" { + t.Errorf("got %v", got) + } +} + +func TestPushFormFromDetachedHead(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "--detach") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin main" { + t.Errorf("got %v", got) + } +} + +func TestRefspecEndToEnd(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.git("checkout", "-q", "-b", "feature") + repo.write("b.txt", "on feature\n") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-m", "from feature")); got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 2 { + t.Error("the remote's main received the feature commit") + } + if origin.lastMessage() != "from feature" { + t.Errorf("got %q", origin.lastMessage()) + } +} + +func TestRebaseThenPush(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("ours.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) + colleague.write("theirs.txt", "from the other machine\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main", "-m", "made elsewhere")) + + repo.write("ours.txt", "updated here\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R", "-m", "made here")) + if got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 3 { + t.Error("base, theirs, ours: one linear history") + } + if origin.lastMessage() != "made here" { + t.Error("our commit was rebased on top") + } + if !strings.Contains(repo.git("log", "--pretty=%s"), "made elsewhere") { + t.Error("their commit is now part of our local history") + } +} + +func TestMergeGuardSkipsMidMerge(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if !repo.midMerge() { + t.Fatal("setup: repo should be mid-merge") + } + if got := autoCommit(repo.spec("-M")); got.Kind != SkippedMerge { + t.Errorf("got %+v", got) + } + if !repo.midMerge() { + t.Error("the merge is left exactly as it was") + } +} + +func TestWithoutMergeGuardTheMergeIsCommitted(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.midMerge() { + t.Error("the commit concluded the merge, as gitwatch would") + } +} + +func TestSubdirectoryTargetCommitsOnlyTheSubtree(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("outer.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "sub/inner.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestFileTargetCommitsOnlyThatFile(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "a.txt")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("b.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "a.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestOutsideChangesOnlyReportClean(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("outer.txt", "elsewhere\n") + before := repo.commitCount() + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != before { + t.Error("no commit should appear") + } +} + +func TestDetachedGitDir(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "base\n") + autoCommit(repo.spec()) + gitDir := filepath.Join(t.TempDir(), "elsewhere.git") + if err := os.Rename(filepath.Join(repo.path, ".git"), gitDir); err != nil { + t.Fatal(err) + } + spec := repo.spec("-g", gitDir) + if !isRepo(repo.path, gitDir) { + t.Error("the daemon/CLI validation path must accept a -g repo") + } + repo.write("a.txt", "changed\n") + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if _, out := gitRun([]string{"rev-list", "--count", "HEAD"}, repo.path, gitDir); out != "2" { + t.Errorf("commits = %s", out) + } + if got := autoCommit(spec); got.Kind != Clean { + t.Error("and the change was fully committed") + } +} + +func TestFailingPreCommitHookReportsCommitFailed(t *testing.T) { + repo := newTestRepo(t) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "1") + got := autoCommit(repo.spec()) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } +} + +// The push still runs after a failed commit (gitwatch parity), but its result +// must not mask the commit failure: the menu/status row has to say "commit +// failing", not "pushed". +func TestFailingCommitIsStillReportedWhenThePushSucceeds(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("seed.txt", "1") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "2") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } + if origin.commitCount() != 1 || repo.commitCount() != 1 { + t.Error("the blocked commit never happened, and the push had nothing new to send") + } +} + +func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("shared.txt", "original\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) // someone else pushes first + colleague.write("shared.txt", "colleague's version\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main")) + + repo.write("shared.txt", "our conflicting version\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != RebaseConflict { + t.Fatalf("expected rebaseConflict, got %+v", got) + } + // gitwatch parity: no abort. The conflicted rebase is left in progress + // for the user to resolve; we only make it visible in status. + if !repo.midRebase() { + t.Error("the conflicted rebase is left in progress") + } +} + +// 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 the macOS test +// suite; both implementations measure themselves against that same copy. +func gitwatchScript(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + script := filepath.Join(wd, "..", "macos", "Tests", "Reference", "gitwatch.sh") + if _, err := os.Stat(script); err != nil { + t.Fatalf("vendored gitwatch.sh not found at %s", script) + } + return script +} + +// A watcher stub that exits immediately: gitwatch runs its -f startup +// commit, the watch pipe hits EOF, and the script terminates. +func stubWatcher(t *testing.T) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "inotifywait") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + return stub +} + +// Run upstream gitwatch for exactly one commit cycle on `target`. +func runOneCycle(t *testing.T, flags []string, target string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + args := append([]string{gitwatchScript(t), "-f"}, flags...) + args = append(args, target) + cmd := exec.CommandContext(ctx, "bash", args...) + cmd.Env = append(os.Environ(), "GW_INW_BIN="+stubWatcher(t)) + cmd.Run() // fire-and-forget, like gitwatch itself +} + +// Build two identical worlds, run the model on one and our engine on the +// other with the same flags, and return both fingerprints. +func twins(t *testing.T, flags []string, withRemote bool, targetSuffix string, + setup func(*testRepo, *bareRemote)) (model, ours repoState) { + t.Helper() + target := func(repo *testRepo) string { + if targetSuffix != "" { + return filepath.Join(repo.path, targetSuffix) + } + return repo.path + } + + a := newTestRepo(t) + var ra *bareRemote + if withRemote { + ra = a.addOrigin() + } + setup(a, ra) + runOneCycle(t, flags, target(a)) + model = stateOf(a, ra) + + b := newTestRepo(t) + var rb *bareRemote + if withRemote { + rb = b.addOrigin() + } + setup(b, rb) + spec, errMsg := parseRepoSpec(append(flags, target(b))) + if spec == nil { + t.Fatal(errMsg) + } + autoCommit(spec) + ours = stateOf(b, rb) + return model, ours +} + +func expectParity(t *testing.T, model, ours repoState) { + t.Helper() + if model != ours { + t.Errorf("state diverged\n gitwatch: %v\n ours: %v", model, ours) + } +} + +// Plain-git scenario builders (no engine involvement). +func seed(repo *testRepo) { + repo.write("seed.txt", "seed\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") +} + +func seedAndPush(repo *testRepo) { + seed(repo) + repo.git("push", "-q", "origin", "main") + repo.trackOrigin() +} + +func colleaguePushes(t *testing.T, remote *bareRemote, file, message string) { + colleague := newCloneOf(t, remote) + colleague.write(file, "from the other machine\n") + colleague.git("add", "-A") + colleague.git("commit", "-q", "-m", message) + colleague.git("push", "-q", "origin", "main") +} + +func TestParityCleanRepo(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + }) + expectParity(t, model, ours) + if ours.commitCount != 1 { + t.Errorf("neither side commits anything: %v", ours) + } +} + +func TestParityPlainCommit(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.lastMessage != "cycle" || ours.pendingChanges != 0 { + t.Errorf("same commit, same message, clean tree afterwards: %v", ours) + } +} + +// Sharp corner, kept for upstream parity: -d passes to date(1) raw and only +// the first %d is spliced. The year-only format keeps the spliced date +// identical across the two runs, so the fingerprints compare exactly. +func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { + model, ours := twins(t, []string{"-m", "at %d then %d", "-d", "+%Y"}, false, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if !strings.HasPrefix(ours.lastMessage, "at 2") || !strings.HasSuffix(ours.lastMessage, " then %d") { + t.Errorf("got %q", ours.lastMessage) + } +} + +func TestParityPushToRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seedAndPush(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 2 || ours.remoteLastMessage != "cycle" { + t.Errorf("both push the commit: %v", ours) + } +} + +func TestParityUnreachableRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.setOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 { + t.Error("fire-and-forget: the commit still happens") + } +} + +// Upstream runs the pull and the push after `git commit` whether or not the +// commit succeeded, so a repo whose commit a hook blocks still pushes what is +// already committed. The seed here is deliberately left unpushed, which is what +// lets the two worlds disagree if we ever short-circuit on a failed commit. +func TestParityFailedCommitStillPushes(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) // committed locally, never pushed + repo.installFailingPreCommitHook("lint says no") + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 1 { + t.Errorf("the already-committed seed reaches the remote on both sides: %v", ours) + } + if ours.commitCount != 1 || ours.pendingChanges == 0 { + t.Errorf("the hook blocked the new commit on both sides: %v", ours) + } +} + +func TestParityMergeGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-M"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if !ours.midMerge { + t.Error("the merge is left untouched on both sides") + } +} + +func TestParityMergeCommittedWithoutGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if ours.midMerge { + t.Error("the cycle concluded the merge on both sides") + } +} + +func TestParityRebaseHappyPath(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "theirs.txt", "made elsewhere") + repo.write("ours.txt", "made here\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 3 { + t.Error("seed, theirs, ours: one linear history") + } + if ours.remoteLastMessage != "cycle" { + t.Errorf("got %q", ours.remoteLastMessage) + } +} + +func TestParitySubdirectoryTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "sub", func(repo *testRepo, _ *bareRemote) { + repo.write("sub/inner.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("outer.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityFileTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { + repo.write("a.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("b.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityRebaseConflictLeftInProgress(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "seed.txt", "conflicting edit") + repo.write("seed.txt", "our conflicting edit\n") + }) + expectParity(t, model, ours) + if !ours.midRebase { + t.Error("both sides stop mid-rebase; -M is the only guard") + } +} diff --git a/linux/gitengine_test.go b/linux/gitengine_test.go deleted file mode 100644 index 76b0777..0000000 --- a/linux/gitengine_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// Engine tests drive autoCommit / push against real throwaway git repos, -// asserting both the reported outcome and the repo state left behind -// (the gitwatch parity contract: same commands, same end state). - -func TestCleanRepoDoesNothing(t *testing.T) { - repo := newTestRepo(t) - repo.write("seed.txt", "v1") - autoCommit(repo.spec()) // absorb the initial commit - if got := autoCommit(repo.spec()); got.Kind != Clean { - t.Errorf("got %+v", got) - } - if repo.commitCount() != 1 { - t.Error("no extra commit should appear") - } -} - -func TestChangesCommitLocallyWithoutRemote(t *testing.T) { - repo := newTestRepo(t) - repo.write("notes.txt", "hello") - if got := autoCommit(repo.spec()); got.Kind != Committed { - t.Errorf("got %+v", got) - } - if repo.commitCount() != 1 { - t.Errorf("commits = %d", repo.commitCount()) - } - if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { - t.Errorf("default message expected, got: %s", repo.lastMessage()) - } -} - -func TestCustomMessageAndDateExpansion(t *testing.T) { - repo := newTestRepo(t) - repo.write("a.txt", "1") - autoCommit(repo.spec("-m", "saved on %d", "-d", "+%Y")) - if !strings.HasPrefix(repo.lastMessage(), "saved on 2") { // "saved on 2026" - t.Errorf("got: %s", repo.lastMessage()) - } -} - -// Sharp corner, kept for upstream parity: -d goes to date(1) raw, so a -// format without a leading + splices an empty date. Git's commit-message -// cleanup then trims the trailing whitespace. -func TestSharpCornerRawDateFormatWithoutPlusSplicesEmptyDate(t *testing.T) { - repo := newTestRepo(t) - repo.write("a.txt", "1") - autoCommit(repo.spec("-m", "at %d", "-d", "%Y")) - if repo.lastMessage() != "at" { - t.Errorf("got %q, want %q", repo.lastMessage(), "at") - } -} - -func TestOnlyTheFirstDateTokenIsExpanded(t *testing.T) { - repo := newTestRepo(t) - repo.write("a.txt", "1") - autoCommit(repo.spec("-m", "saved %d then %d", "-d", "+%Y")) - got := repo.lastMessage() - if !strings.HasPrefix(got, "saved 2") || !strings.HasSuffix(got, " then %d") { - t.Errorf("upstream splices the date into the first %%d only, got: %s", got) - } -} - -func TestPushToRemote(t *testing.T) { - repo := newTestRepo(t) - origin := repo.addOrigin() - repo.write("a.txt", "1") - if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { - t.Errorf("got %+v", got) - } - if origin.commitCount() != 1 { - t.Error("the remote should have the commit") - } -} - -func TestUnreachableRemoteReportsPushFailedButCommitSurvives(t *testing.T) { - repo := newTestRepo(t) - repo.addOriginURL(filepath.Join(t.TempDir(), "not-a-remote.git")) - repo.write("a.txt", "1") - got := autoCommit(repo.spec("-r", "origin", "-b", "main")) - if got.Kind != PushFailed { - t.Fatalf("expected pushFailed, got %+v", got) - } - if got.Detail == "" { - t.Error("the git error is captured for status") - } - if repo.commitCount() != 1 { - t.Error("gitwatch parity: commit stays, only the push failed") - } -} - -func TestPushRetriesStrandedCommitAfterOutage(t *testing.T) { - repo := newTestRepo(t) - origin := newBareRemote(t) - repo.addOriginURL(filepath.Join(t.TempDir(), "offline.git")) // remote "down" - repo.write("a.txt", "1") - if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != PushFailed { - t.Fatalf("setup: expected the first push to fail, got %+v", got) - } - repo.setOriginURL(origin.path) // remote "back up" - if got := push(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { - t.Errorf("got %+v", got) - } - if origin.commitCount() != 1 { - t.Error("the earlier commit reached the remote") - } -} - -func TestPullFailureWhileOfflineIsTransientNotConflict(t *testing.T) { - repo := newTestRepo(t) - repo.addOriginURL(filepath.Join(t.TempDir(), "gone.git")) - repo.write("a.txt", "1") - got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) - if got.Kind != PushFailed { - t.Fatalf("expected pushFailed, got %+v", got) - } - if repo.midRebase() { - t.Error("no rebase was ever started, so the retry loop may heal this") - } -} - -func TestIdempotentRetryStillReportsPushed(t *testing.T) { - repo := newTestRepo(t) - repo.addOrigin() - repo.write("a.txt", "1") - spec := repo.spec("-r", "origin", "-b", "main") - autoCommit(spec) - if got := push(spec); got.Kind != Pushed { // "Everything up-to-date" - t.Errorf("got %+v", got) - } -} - -func TestPushFormWithoutBranch(t *testing.T) { - spec, _ := parseRepoSpec([]string{"-r", "origin", "/tmp/x"}) - got := pushArgs("origin", spec) - if strings.Join(got, " ") != "push origin" { - t.Errorf("got %v", got) - } -} - -func TestPushFormWithBranch(t *testing.T) { - repo := newTestRepo(t) - repo.write("a.txt", "1") - autoCommit(repo.spec()) - repo.git("checkout", "-q", "-b", "feature") - got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) - if strings.Join(got, " ") != "push origin feature:main" { - t.Errorf("got %v", got) - } -} - -func TestPushFormFromDetachedHead(t *testing.T) { - repo := newTestRepo(t) - repo.write("a.txt", "1") - autoCommit(repo.spec()) - repo.git("checkout", "-q", "--detach") - got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) - if strings.Join(got, " ") != "push origin main" { - t.Errorf("got %v", got) - } -} - -func TestRefspecEndToEnd(t *testing.T) { - repo := newTestRepo(t) - origin := repo.addOrigin() - repo.write("a.txt", "base\n") - autoCommit(repo.spec("-r", "origin", "-b", "main")) - repo.git("checkout", "-q", "-b", "feature") - repo.write("b.txt", "on feature\n") - if got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-m", "from feature")); got.Kind != Pushed { - t.Fatalf("got %+v", got) - } - if origin.commitCount() != 2 { - t.Error("the remote's main received the feature commit") - } - if origin.lastMessage() != "from feature" { - t.Errorf("got %q", origin.lastMessage()) - } -} - -func TestRebaseThenPush(t *testing.T) { - repo := newTestRepo(t) - origin := repo.addOrigin() - repo.write("ours.txt", "base\n") - autoCommit(repo.spec("-r", "origin", "-b", "main")) - repo.trackOrigin() - - colleague := newCloneOf(t, origin) - colleague.write("theirs.txt", "from the other machine\n") - autoCommit(colleague.spec("-r", "origin", "-b", "main", "-m", "made elsewhere")) - - repo.write("ours.txt", "updated here\n") - got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R", "-m", "made here")) - if got.Kind != Pushed { - t.Fatalf("got %+v", got) - } - if origin.commitCount() != 3 { - t.Error("base, theirs, ours: one linear history") - } - if origin.lastMessage() != "made here" { - t.Error("our commit was rebased on top") - } - if !strings.Contains(repo.git("log", "--pretty=%s"), "made elsewhere") { - t.Error("their commit is now part of our local history") - } -} - -func TestMergeGuardSkipsMidMerge(t *testing.T) { - repo := newTestRepo(t) - repo.conflictedMerge() - if !repo.midMerge() { - t.Fatal("setup: repo should be mid-merge") - } - if got := autoCommit(repo.spec("-M")); got.Kind != SkippedMerge { - t.Errorf("got %+v", got) - } - if !repo.midMerge() { - t.Error("the merge is left exactly as it was") - } -} - -func TestWithoutMergeGuardTheMergeIsCommitted(t *testing.T) { - repo := newTestRepo(t) - repo.conflictedMerge() - if got := autoCommit(repo.spec()); got.Kind != Committed { - t.Errorf("got %+v", got) - } - if repo.midMerge() { - t.Error("the commit concluded the merge, as gitwatch would") - } -} - -func TestSubdirectoryTargetCommitsOnlyTheSubtree(t *testing.T) { - repo := newTestRepo(t) - repo.write("sub/inner.txt", "v1\n") - autoCommit(repo.spec()) - repo.write("sub/inner.txt", "v2\n") - repo.write("outer.txt", "left alone\n") - spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) - if got := autoCommit(spec); got.Kind != Committed { - t.Fatalf("got %+v", got) - } - if pendingCount(repo.path, "") != 1 { - t.Error("outer.txt stays uncommitted") - } - if repo.git("show", "--name-only", "--pretty=") != "sub/inner.txt" { - t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) - } -} - -func TestFileTargetCommitsOnlyThatFile(t *testing.T) { - repo := newTestRepo(t) - repo.write("a.txt", "v1\n") - autoCommit(repo.spec()) - repo.write("a.txt", "v2\n") - repo.write("b.txt", "left alone\n") - spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "a.txt")}) - if got := autoCommit(spec); got.Kind != Committed { - t.Fatalf("got %+v", got) - } - if pendingCount(repo.path, "") != 1 { - t.Error("b.txt stays uncommitted") - } - if repo.git("show", "--name-only", "--pretty=") != "a.txt" { - t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) - } -} - -func TestOutsideChangesOnlyReportClean(t *testing.T) { - repo := newTestRepo(t) - repo.write("sub/inner.txt", "v1\n") - autoCommit(repo.spec()) - repo.write("outer.txt", "elsewhere\n") - before := repo.commitCount() - spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) - if got := autoCommit(spec); got.Kind != Clean { - t.Errorf("got %+v", got) - } - if repo.commitCount() != before { - t.Error("no commit should appear") - } -} - -func TestDetachedGitDir(t *testing.T) { - repo := newTestRepo(t) - repo.write("a.txt", "base\n") - autoCommit(repo.spec()) - gitDir := filepath.Join(t.TempDir(), "elsewhere.git") - if err := os.Rename(filepath.Join(repo.path, ".git"), gitDir); err != nil { - t.Fatal(err) - } - spec := repo.spec("-g", gitDir) - if !isRepo(repo.path, gitDir) { - t.Error("the daemon/CLI validation path must accept a -g repo") - } - repo.write("a.txt", "changed\n") - if got := autoCommit(spec); got.Kind != Committed { - t.Fatalf("got %+v", got) - } - if _, out := gitRun([]string{"rev-list", "--count", "HEAD"}, repo.path, gitDir); out != "2" { - t.Errorf("commits = %s", out) - } - if got := autoCommit(spec); got.Kind != Clean { - t.Error("and the change was fully committed") - } -} - -func TestFailingPreCommitHookReportsCommitFailed(t *testing.T) { - repo := newTestRepo(t) - repo.installFailingPreCommitHook("lint says no") - repo.write("a.txt", "1") - got := autoCommit(repo.spec()) - if got.Kind != CommitFailed { - t.Fatalf("expected commitFailed, got %+v", got) - } - if !strings.Contains(got.Detail, "lint says no") { - t.Errorf("hook output surfaces: got %q", got.Detail) - } -} - -// The push still runs after a failed commit (gitwatch parity), but its result -// must not mask the commit failure: the menu/status row has to say "commit -// failing", not "pushed". -func TestFailingCommitIsStillReportedWhenThePushSucceeds(t *testing.T) { - repo := newTestRepo(t) - origin := repo.addOrigin() - repo.write("seed.txt", "1") - autoCommit(repo.spec("-r", "origin", "-b", "main")) - repo.installFailingPreCommitHook("lint says no") - repo.write("a.txt", "2") - got := autoCommit(repo.spec("-r", "origin", "-b", "main")) - if got.Kind != CommitFailed { - t.Fatalf("expected commitFailed, got %+v", got) - } - if !strings.Contains(got.Detail, "lint says no") { - t.Errorf("hook output surfaces: got %q", got.Detail) - } - if origin.commitCount() != 1 || repo.commitCount() != 1 { - t.Error("the blocked commit never happened, and the push had nothing new to send") - } -} - -func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { - repo := newTestRepo(t) - origin := repo.addOrigin() - repo.write("shared.txt", "original\n") - autoCommit(repo.spec("-r", "origin", "-b", "main")) - repo.trackOrigin() - - colleague := newCloneOf(t, origin) // someone else pushes first - colleague.write("shared.txt", "colleague's version\n") - autoCommit(colleague.spec("-r", "origin", "-b", "main")) - - repo.write("shared.txt", "our conflicting version\n") - got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) - if got.Kind != RebaseConflict { - t.Fatalf("expected rebaseConflict, got %+v", got) - } - // gitwatch parity: no abort. The conflicted rebase is left in progress - // for the user to resolve; we only make it visible in status. - if !repo.midRebase() { - t.Error("the conflicted rebase is left in progress") - } -} diff --git a/linux/helpers_test.go b/linux/helpers_test.go deleted file mode 100644 index b466d4d..0000000 --- a/linux/helpers_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" -) - -// A throwaway git repo for the engine to run against: real git, real commits, -// so tests assert both the reported outcome and the repo state left behind. -type testRepo struct { - t *testing.T - path string -} - -func newTestRepo(t *testing.T) *testRepo { - t.Helper() - r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "repo")} - os.MkdirAll(r.path, 0o755) - r.git("init", "-q", "-b", "main") - r.configureUser() - return r -} - -// A second working copy of a remote (a colleague's machine). -func newCloneOf(t *testing.T, remote *bareRemote) *testRepo { - t.Helper() - r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "clone")} - runCommand("git", []string{"clone", "-q", remote.path, r.path}, "") - r.configureUser() - return r -} - -func (r *testRepo) configureUser() { - r.git("config", "user.email", "tests@gitwatchd.local") - r.git("config", "user.name", "gitwatchd tests") - r.git("config", "commit.gpgsign", "false") -} - -// The spec `gitwatchd add ` would produce. -func (r *testRepo) spec(flags ...string) *RepoSpec { - r.t.Helper() - spec, errMsg := parseRepoSpec(append(flags, r.path)) - if spec == nil { - r.t.Fatalf("spec did not parse: %s", errMsg) - } - return spec -} - -func (r *testRepo) git(args ...string) string { - _, out := gitRun(args, r.path, "") - return out -} - -func (r *testRepo) write(file, contents string) { - r.t.Helper() - full := filepath.Join(r.path, file) - os.MkdirAll(filepath.Dir(full), 0o755) - if err := os.WriteFile(full, []byte(contents), 0o644); err != nil { - r.t.Fatal(err) - } -} - -func (r *testRepo) commitCount() int { - n, _ := strconv.Atoi(r.git("rev-list", "--count", "HEAD")) - return n -} - -func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%s") } - -func (r *testRepo) midMerge() bool { - _, err := os.Stat(filepath.Join(r.path, ".git", "MERGE_HEAD")) - return err == nil -} - -func (r *testRepo) midRebase() bool { - for _, d := range []string{"rebase-merge", "rebase-apply"} { - if _, err := os.Stat(filepath.Join(r.path, ".git", d)); err == nil { - return true - } - } - return false -} - -// Wire up an "origin" this repo pushes to (a real bare repo). -func (r *testRepo) addOrigin() *bareRemote { - remote := newBareRemote(r.t) - r.git("remote", "add", "origin", remote.path) - return remote -} - -// An "origin" pointing at a URL that does not exist (an unreachable 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 -} - -// A bare repo standing in for the server side (GitHub etc). -type bareRemote struct { - path string -} - -func newBareRemote(t *testing.T) *bareRemote { - t.Helper() - b := &bareRemote{path: filepath.Join(t.TempDir(), "remote.git")} - runCommand("git", []string{"init", "-q", "--bare", "-b", "main", b.path}, "") - return b -} - -func (b *bareRemote) commitCount() int { - code, out := gitRun([]string{"rev-list", "--count", "main"}, b.path, "") - if code != 0 { - return 0 - } - n, _ := strconv.Atoi(out) - return n -} - -func (b *bareRemote) lastMessage() string { - _, out := gitRun([]string{"log", "-1", "--pretty=%s", "main"}, b.path, "") - return out -} - -// The built gitwatchd binary, for tests that exercise the real daemon. -var testBinary string - -func TestMain(m *testing.M) { - 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) -} - -// Run the built binary with an isolated HOME/config/state and return its -// exit code and combined output. -func runCLI(env []string, args ...string) (int, string) { - cmd := exec.Command(testBinary, 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") -} diff --git a/linux/main.go b/linux/main.go deleted file mode 100644 index 515d85c..0000000 --- a/linux/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "os" - -func main() { - os.Exit(cliRun(os.Args[1:])) -} diff --git a/linux/parity_test.go b/linux/parity_test.go deleted file mode 100644 index a70f235..0000000 --- a/linux/parity_test.go +++ /dev/null @@ -1,316 +0,0 @@ -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 the macOS test -// suite; both implementations measure themselves against that same copy. -func gitwatchScript(t *testing.T) string { - t.Helper() - wd, _ := os.Getwd() - script := filepath.Join(wd, "..", "macos", "Tests", "Reference", "gitwatch.sh") - if _, err := os.Stat(script); err != nil { - t.Fatalf("vendored gitwatch.sh not found at %s", script) - } - return script -} - -// A watcher stub that exits immediately: gitwatch runs its -f startup -// commit, the watch pipe hits EOF, and the script terminates. -func stubWatcher(t *testing.T) string { - t.Helper() - stub := filepath.Join(t.TempDir(), "inotifywait") - if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - return stub -} - -// Run upstream gitwatch for exactly one commit cycle on `target`. -func runOneCycle(t *testing.T, flags []string, target string) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - args := append([]string{gitwatchScript(t), "-f"}, flags...) - args = append(args, target) - cmd := exec.CommandContext(ctx, "bash", args...) - cmd.Env = append(os.Environ(), "GW_INW_BIN="+stubWatcher(t)) - cmd.Run() // fire-and-forget, like gitwatch itself -} - -// Build two identical worlds, run the model on one and our engine on the -// other with the same flags, and return both fingerprints. -func twins(t *testing.T, flags []string, withRemote bool, targetSuffix string, - setup func(*testRepo, *bareRemote)) (model, ours repoState) { - t.Helper() - target := func(repo *testRepo) string { - if targetSuffix != "" { - return filepath.Join(repo.path, targetSuffix) - } - return repo.path - } - - a := newTestRepo(t) - var ra *bareRemote - if withRemote { - ra = a.addOrigin() - } - setup(a, ra) - runOneCycle(t, flags, target(a)) - model = stateOf(a, ra) - - b := newTestRepo(t) - var rb *bareRemote - if withRemote { - rb = b.addOrigin() - } - setup(b, rb) - spec, errMsg := parseRepoSpec(append(flags, target(b))) - if spec == nil { - t.Fatal(errMsg) - } - autoCommit(spec) - ours = stateOf(b, rb) - return model, ours -} - -func expectParity(t *testing.T, model, ours repoState) { - t.Helper() - if model != ours { - t.Errorf("state diverged\n gitwatch: %v\n ours: %v", model, ours) - } -} - -// Plain-git scenario builders (no engine involvement). -func seed(repo *testRepo) { - repo.write("seed.txt", "seed\n") - repo.git("add", "-A") - repo.git("commit", "-q", "-m", "seed") -} - -func seedAndPush(repo *testRepo) { - seed(repo) - repo.git("push", "-q", "origin", "main") - repo.trackOrigin() -} - -func colleaguePushes(t *testing.T, remote *bareRemote, file, message string) { - colleague := newCloneOf(t, remote) - colleague.write(file, "from the other machine\n") - colleague.git("add", "-A") - colleague.git("commit", "-q", "-m", message) - colleague.git("push", "-q", "origin", "main") -} - -func TestParityCleanRepo(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { - seed(repo) - }) - expectParity(t, model, ours) - if ours.commitCount != 1 { - t.Errorf("neither side commits anything: %v", ours) - } -} - -func TestParityPlainCommit(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { - seed(repo) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 || ours.lastMessage != "cycle" || ours.pendingChanges != 0 { - t.Errorf("same commit, same message, clean tree afterwards: %v", ours) - } -} - -// Sharp corner, kept for upstream parity: -d passes to date(1) raw and only -// the first %d is spliced. The year-only format keeps the spliced date -// identical across the two runs, so the fingerprints compare exactly. -func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { - model, ours := twins(t, []string{"-m", "at %d then %d", "-d", "+%Y"}, false, "", - func(repo *testRepo, _ *bareRemote) { - seed(repo) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if !strings.HasPrefix(ours.lastMessage, "at 2") || !strings.HasSuffix(ours.lastMessage, " then %d") { - t.Errorf("got %q", ours.lastMessage) - } -} - -func TestParityPushToRemote(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", - func(repo *testRepo, _ *bareRemote) { - seedAndPush(repo) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.remoteCommitCount != 2 || ours.remoteLastMessage != "cycle" { - t.Errorf("both push the commit: %v", ours) - } -} - -func TestParityUnreachableRemote(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", - func(repo *testRepo, _ *bareRemote) { - seed(repo) - repo.setOriginURL(filepath.Join(t.TempDir(), "gone.git")) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 { - t.Error("fire-and-forget: the commit still happens") - } -} - -// Upstream runs the pull and the push after `git commit` whether or not the -// commit succeeded, so a repo whose commit a hook blocks still pushes what is -// already committed. The seed here is deliberately left unpushed, which is what -// lets the two worlds disagree if we ever short-circuit on a failed commit. -func TestParityFailedCommitStillPushes(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", - func(repo *testRepo, _ *bareRemote) { - seed(repo) // committed locally, never pushed - repo.installFailingPreCommitHook("lint says no") - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.remoteCommitCount != 1 { - t.Errorf("the already-committed seed reaches the remote on both sides: %v", ours) - } - if ours.commitCount != 1 || ours.pendingChanges == 0 { - t.Errorf("the hook blocked the new commit on both sides: %v", ours) - } -} - -func TestParityMergeGuard(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-M"}, false, "", func(repo *testRepo, _ *bareRemote) { - repo.conflictedMerge() - }) - expectParity(t, model, ours) - if !ours.midMerge { - t.Error("the merge is left untouched on both sides") - } -} - -func TestParityMergeCommittedWithoutGuard(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { - repo.conflictedMerge() - }) - expectParity(t, model, ours) - if ours.midMerge { - t.Error("the cycle concluded the merge on both sides") - } -} - -func TestParityRebaseHappyPath(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", - func(repo *testRepo, remote *bareRemote) { - seedAndPush(repo) - colleaguePushes(t, remote, "theirs.txt", "made elsewhere") - repo.write("ours.txt", "made here\n") - }) - expectParity(t, model, ours) - if ours.remoteCommitCount != 3 { - t.Error("seed, theirs, ours: one linear history") - } - if ours.remoteLastMessage != "cycle" { - t.Errorf("got %q", ours.remoteLastMessage) - } -} - -func TestParitySubdirectoryTarget(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "sub", func(repo *testRepo, _ *bareRemote) { - repo.write("sub/inner.txt", "v1\n") - repo.git("add", "-A") - repo.git("commit", "-q", "-m", "seed") - repo.write("sub/inner.txt", "v2\n") - repo.write("outer.txt", "left alone\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 || ours.pendingChanges != 1 { - t.Errorf("outer.txt stays uncommitted on both sides: %v", ours) - } -} - -func TestParityFileTarget(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { - repo.write("a.txt", "v1\n") - repo.git("add", "-A") - repo.git("commit", "-q", "-m", "seed") - repo.write("a.txt", "v2\n") - repo.write("b.txt", "left alone\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 || ours.pendingChanges != 1 { - t.Errorf("b.txt stays uncommitted on both sides: %v", ours) - } -} - -func TestParityRebaseConflictLeftInProgress(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", - func(repo *testRepo, remote *bareRemote) { - seedAndPush(repo) - colleaguePushes(t, remote, "seed.txt", "conflicting edit") - repo.write("seed.txt", "our conflicting edit\n") - }) - expectParity(t, model, ours) - if !ours.midRebase { - t.Error("both sides stop mid-rebase; -M is the only guard") - } -} diff --git a/linux/repospec.go b/linux/repospec.go deleted file mode 100644 index 77861aa..0000000 --- a/linux/repospec.go +++ /dev/null @@ -1,188 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "regexp" - "strconv" - "strings" -) - -// A watched-repo specification, expressed in gitwatch's own flag vocabulary so -// that gitwatch users read our config/CLI with zero translation. -// -// gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg] -// [-x pattern] [-M] [-g gitdir] [-e events] -// -// Each config line is exactly the argument string you'd pass to gitwatch. -type RepoSpec struct { - Path string - Settle float64 // -s debounce seconds - DateFormat string // -d - Remote string // -r - Branch string // -b - Rebase bool // -R pull --rebase before push - Message string // -m (%d -> date) - Exclude string // -x regex; last one wins, as upstream - NoMergeCommit bool // -M - CommitOnStart bool // -f commit pending changes when watching starts - GitDir string // -g --git-dir - Paused bool // --paused (gitwatchd extension, not gitwatch: - // config-level so a pause survives restarts) - 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) -} - -// Parse a gitwatch-style argument list into a RepoSpec. -// Returns nil + an error message if there's no valid target. -func parseRepoSpec(args []string) (*RepoSpec, string) { - spec := &RepoSpec{ - Settle: 2, - DateFormat: "+%Y-%m-%d %H:%M:%S", - Message: "gitwatchd auto-commit (%d)", - } - target := "" - haveTarget := false - i := 0 - next := func() (string, bool) { - i++ - if i < len(args) { - return args[i], true - } - return "", false - } - - for i < len(args) { - a := args[i] - switch a { - case "-s": - v, ok := next() - d, err := strconv.ParseFloat(v, 64) - if !ok || err != nil || d < 0 { - return nil, "-s needs a number of seconds, 0 or more" - } - spec.Settle = d - case "-d": - if v, ok := next(); ok { - spec.DateFormat = v - } - case "-r", "-p": // -p: upstream's alias of -r - if v, ok := next(); ok { - spec.Remote = v - } - case "-b": - if v, ok := next(); ok { - spec.Branch = v - } - case "-R": - spec.Rebase = true - case "-m": - if v, ok := next(); ok { - spec.Message = v - } - case "-x": - v, ok := next() - if !ok { - return nil, "-x needs a valid regular expression" - } - if _, err := regexp.Compile(v); err != nil { - return nil, "-x needs a valid regular expression" - } - spec.Exclude = v - case "-M": - spec.NoMergeCommit = true - case "-f": - spec.CommitOnStart = true - case "-g": - if v, ok := next(); ok { - spec.GitDir = v - } - case "-e": - next() // inotify events: accepted, no-op (we watch a fixed gitwatch-like set) - case "--paused": - spec.Paused = true // gitwatchd extension (see RepoSpec) - 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 -} - -// Render the current date exactly as upstream does: the -d value passes to -// date(1) verbatim (the user includes the leading +), and a format date(1) -// rejects yields an empty string. -func formattedDate(fmt string) string { - code, out := runCommand("date", []string{fmt}, "") - if code != 0 { - return "" - } - return out -} diff --git a/linux/state.go b/linux/state.go deleted file mode 100644 index 1bed416..0000000 --- a/linux/state.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" -) - -// A repo's current error, as published by the daemon. Watchers write it, and -// `gitwatchd status` is a view over it. Stored as one JSON file rewritten -// atomically; the daemon is the only writer. -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"` -} - -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 pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } -func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } - -var stateMu sync.Mutex - -func stateErrors() map[string]RepoStatus { - raw, err := os.ReadFile(statePath()) - if err != nil { - return map[string]RepoStatus{} - } - var out map[string]RepoStatus - if json.Unmarshal(raw, &out) != nil || out == nil { - return map[string]RepoStatus{} - } - return out -} - -func stateSet(repoPath string, status *RepoStatus) { - stateMu.Lock() - defer stateMu.Unlock() - errs := stateErrors() - if status == nil { - delete(errs, repoPath) - } else { - errs[repoPath] = *status - } - stateWrite(errs) -} - -func statePrune(keep map[string]bool) { - stateMu.Lock() - defer stateMu.Unlock() - errs := stateErrors() - for path := range errs { - if !keep[path] { - delete(errs, path) - } - } - stateWrite(errs) -} - -func stateWrite(errs map[string]RepoStatus) { - os.MkdirAll(stateDir(), 0o755) - raw, err := json.Marshal(errs) - if err != nil { - return - } - tmp := statePath() + ".tmp" - if os.WriteFile(tmp, raw, 0o644) == nil { - os.Rename(tmp, statePath()) - } -} diff --git a/linux/statusformat.go b/linux/statusformat.go deleted file mode 100644 index 1f50f49..0000000 --- a/linux/statusformat.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "fmt" - "math" - "time" -) - -// Pure string formatting for `gitwatchd status`. Same rows and strings as the -// macOS menu, so the habit transfers between machines unchanged. - -// 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) -} - -func truncated(s string, max int) string { - runes := []rune(s) - if len(runes) <= max { - return s - } - return string(runes[:max-1]) + "…" -} - -// "just now", "40s ago", "5m ago", "3h ago", "2d ago". -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))) -} - -// Push retry backoff: 30s doubling to a 5 minute cap. -const ( - backoffFirst = 30 * time.Second - backoffCap = 300 * time.Second -) - -func backoffDelay(afterFailures int) time.Duration { - if afterFailures <= 1 { - return backoffFirst - } - d := time.Duration(float64(backoffFirst) * math.Pow(2, float64(afterFailures-1))) - if d > backoffCap { - return backoffCap - } - return d -} diff --git a/linux/systemd.go b/linux/systemd.go deleted file mode 100644 index bb0b223..0000000 --- a/linux/systemd.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "time" -) - -// Autostart = a systemd user unit, the standard way for a per-user daemon to -// survive reboots and headless boots (with lingering). When systemd is absent -// we say so and do nothing: no shell-profile edits, ever. - -func unitPath() string { - return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") -} - -func systemctlPresent() bool { - _, err := exec.LookPath("systemctl") - return err == nil -} - -func unitInstalled() bool { - _, err := os.Stat(unitPath()) - return err == nil -} - -func systemctlUser(args ...string) (int, string) { - return runCommand("systemctl", append([]string{"--user"}, args...), "") -} - -func unitActive() bool { - _, out := systemctlUser("is-active", "gitwatchd") - return out == "active" -} - -func autostartOn() int { - if !systemctlPresent() { - warn("systemd not found: autostart needs a systemd user session.\n" + - " run the daemon manually instead: gitwatchd start") - return 1 - } - exe, err := os.Executable() - if err != nil { - warn("cannot resolve the gitwatchd binary path: " + err.Error()) - return 1 - } - exe, _ = filepath.EvalSymlinks(exe) - unit := fmt.Sprintf(`[Unit] -Description=gitwatchd: watch git repos and auto-commit changes - -[Service] -ExecStart=%s daemon -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target -`, exe) - if err := os.MkdirAll(filepath.Dir(unitPath()), 0o755); err != nil { - warn("cannot create " + filepath.Dir(unitPath()) + ": " + err.Error()) - return 1 - } - if err := os.WriteFile(unitPath(), []byte(unit), 0o644); err != nil { - warn("cannot write " + unitPath() + ": " + err.Error()) - return 1 - } - systemctlUser("daemon-reload") - // A directly spawned daemon holds the single-instance lock and would - // make the unit fail; hand it over to systemd. - if isDaemonRunning() && !unitActive() { - if pid := daemonPid(); pid > 0 { - syscall.Kill(pid, syscall.SIGTERM) - for i := 0; i < 50 && isDaemonRunning(); i++ { - time.Sleep(100 * time.Millisecond) - } - } - } - if code, out := systemctlUser("enable", "--now", "gitwatchd"); code != 0 { - warn("systemctl --user enable --now gitwatchd failed: " + out) - return 1 - } - // Lingering keeps the user manager (and the daemon) alive without an - // open session: headless boots, logged-out laptops. - if code, out := runCommand("loginctl", []string{"enable-linger", os.Getenv("USER")}, ""); code != 0 { - fmt.Println("✓ autostart: on (systemd user unit enabled)") - fmt.Println(" note: loginctl enable-linger failed (" + strings.TrimSpace(out) + ")") - fmt.Println(" without lingering the daemon stops when you log out") - return 0 - } - fmt.Println("✓ autostart: on (systemd user unit enabled, survives logout and reboot)") - return 0 -} - -func autostartOff() int { - if !systemctlPresent() { - warn("systemd not found: nothing to turn off (autostart was never installed)") - return 1 - } - if !unitInstalled() { - fmt.Println("autostart: already off") - return 0 - } - systemctlUser("disable", "--now", "gitwatchd") - os.Remove(unitPath()) - systemctlUser("daemon-reload") - fmt.Println("✓ autostart: off") - return 0 -} - -func autostartStatus() int { - if !systemctlPresent() { - fmt.Println("autostart: unavailable (systemd not found); run the daemon with: gitwatchd start") - return 0 - } - if !unitInstalled() { - fmt.Println("autostart: off") - return 0 - } - _, enabled := systemctlUser("is-enabled", "gitwatchd") - state := "on" - if enabled != "enabled" { - state = "installed but " + enabled - } - if unitActive() { - fmt.Printf("autostart: %s (daemon running)\n", state) - } else { - fmt.Printf("autostart: %s (daemon not running)\n", state) - } - return 0 -} diff --git a/linux/watcher.go b/linux/watcher.go deleted file mode 100644 index 9389073..0000000 --- a/linux/watcher.go +++ /dev/null @@ -1,313 +0,0 @@ -package main - -import ( - "bytes" - "io/fs" - "os" - "path/filepath" - "strings" - "sync" - "syscall" - "time" - "unsafe" -) - -// Recursive inotify watcher for one repo, with a debounce (gitwatch's -s) and -// .git-churn filtering so our own commits don't retrigger the watcher. Also -// honors gitwatch's -x exclude patterns. -// -// On top of the gitwatch cycle it keeps an additive reliability layer: per-repo -// error state and automatic push retries with backoff. The layer never changes -// what a commit cycle does; it only re-runs the push stage of one that failed. - -// 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 - -type repoError struct { - outcome Outcome - lastAttempt time.Time - attempts int // consecutive failures - nextRetry *time.Time // nil: no auto retry, waits for a change -} - -type repoWatcher struct { - spec *RepoSpec - - fd int - wds map[int]string // watch descriptor -> directory path - mu sync.Mutex // guards wds and watchErr - - events chan struct{} - flush chan struct{} - stop chan struct{} - done chan struct{} - - lastError *repoError - watchErr string // e.g. the inotify watch limit; surfaced, never fatal - - // Called after every cycle with the repo path and its error state - // (nil while healthy); the daemon publishes it for `status`. - onState func(path string, status *RepoStatus) - logf func(format string, args ...any) -} - -func newRepoWatcher(spec *RepoSpec, onState 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, - fd: fd, - wds: map[int]string{}, - events: make(chan struct{}, 64), - flush: make(chan struct{}, 1), - stop: make(chan struct{}), - done: make(chan struct{}), - onState: onState, - 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.addWatchTracked(filepath.Dir(spec.Path)) - } else { - w.addRecursive(spec.Path) - } - go w.readLoop() - go w.run() - 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) addWatchTracked(dir string) { - wd, err := syscall.InotifyAddWatch(w.fd, dir, watchMask) - if err != nil { - w.addWatchError(dir, err) - return - } - w.mu.Lock() - w.wds[wd] = dir - w.mu.Unlock() -} - -func (w *repoWatcher) addWatchError(dir string, err error) { - w.mu.Lock() - defer w.mu.Unlock() - if err == syscall.ENOSPC { - w.watchErr = "inotify watch limit reached: raise fs.inotify.max_user_watches " + - "(sudo sysctl fs.inotify.max_user_watches=524288)" - } else if !os.IsNotExist(err) { - w.watchErr = "watch failed for " + dir + ": " + err.Error() - } -} - -func (w *repoWatcher) addRecursive(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.addWatchTracked(path) - return nil - }) -} - -func (w *repoWatcher) readLoop() { - buf := make([]byte, 64*1024) - for { - n, err := syscall.Read(w.fd, buf) - if err != nil || n <= 0 { - return // fd closed by Stop - } - offset := 0 - for offset+syscall.SizeofInotifyEvent <= n { - raw := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset])) - name := "" - if raw.Len > 0 { - b := buf[offset+syscall.SizeofInotifyEvent : offset+syscall.SizeofInotifyEvent+int(raw.Len)] - name = string(bytes.TrimRight(b, "\x00")) - } - offset += syscall.SizeofInotifyEvent + int(raw.Len) - w.handleEvent(int(raw.Wd), raw.Mask, name) - } - } -} - -func (w *repoWatcher) handleEvent(wd int, mask uint32, name string) { - if mask&syscall.IN_Q_OVERFLOW != 0 { - w.signal() // events were dropped; a cycle will catch whatever changed - return - } - w.mu.Lock() - dir, known := w.wds[wd] - if mask&syscall.IN_IGNORED != 0 { - delete(w.wds, wd) - } - w.mu.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 - } - w.signal() - 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.addRecursive(path) // a new subtree starts being watched immediately - } - w.signal() -} - -func (w *repoWatcher) signal() { - select { - case w.events <- struct{}{}: - default: - } -} - -// Run one cycle soon regardless of file events (-f at start, resume catch-up). -func (w *repoWatcher) flushNow() { - select { - case w.flush <- struct{}{}: - default: - } -} - -func (w *repoWatcher) run() { - defer close(w.done) - 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.events: - // 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.flush: - w.report(autoCommit(w.spec), retry) - case <-retry.C: - if w.lastError != nil { - w.report(push(w.spec), retry) - } - case <-w.stop: - debounce.Stop() - retry.Stop() - return - } - } -} - -// 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.publish() - w.logOutcome(outcome) -} - -func (w *repoWatcher) publish() { - 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 err := w.watchError(); err != "" { - status = &RepoStatus{ErrorLabel: "watch failing", Detail: err, - Attempts: 1, LastAttempt: time.Now().Unix()} - } - w.onState(w.spec.Path, status) -} - -func (w *repoWatcher) watchError() string { - w.mu.Lock() - defer w.mu.Unlock() - return w.watchErr -} - -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.stop) - syscall.Close(w.fd) - <-w.done -} diff --git a/linux/watcher_test.go b/linux/watcher_test.go deleted file mode 100644 index 155252e..0000000 --- a/linux/watcher_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "syscall" - "testing" - "time" -) - -// Live inotify tests: real filesystem events driving real commits, with a -// short settle so the suite stays fast. - -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.addWatchError("/some/dir", syscall.ENOSPC) - w.publish() - 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) - } -} From 7b924da323ffee2d2742e6a3794ba554b2229a48 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 13:37:17 +0000 Subject: [PATCH 04/19] Trim and tighten comments in the consolidated CLI and git driver Co-Authored-By: Claude Fable 5 --- linux/cli.go | 28 ++++++++++++---------------- linux/git_driver.go | 20 ++++---------------- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index b7d6038..491216a 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -28,13 +28,12 @@ type ConfigError struct { RepoPath string // set when there is a path to re-check for healing } -// A watched-repo specification, expressed in gitwatch's own flag vocabulary so -// that gitwatch users read our config/CLI with zero translation. +// A watched-repo specification, expressed in gitwatch's flag vocabulary. // // gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg] // [-x pattern] [-M] [-g gitdir] [-e events] // -// Each config line is exactly the argument string you'd pass to gitwatch. +// 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 @@ -47,14 +46,14 @@ type RepoSpec struct { NoMergeCommit bool // -M CommitOnStart bool // -f commit pending changes when watching starts GitDir string // -g --git-dir - Paused bool // --paused (gitwatchd extension, not gitwatch: - // config-level so a pause survives restarts) - 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 + 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) Name() string { + return filepath.Base(s.Path) +} func (s RepoSpec) IsFileTarget() bool { info, err := os.Stat(s.Path) @@ -232,8 +231,6 @@ func cliSetPaused(args []string, paused bool) int { return 0 } -// The status rows, same strings as the macOS menu, plus the daemon's -// published error state when it is running. func cliStatus() int { running := isDaemonRunning() state := "not running" @@ -409,6 +406,7 @@ func cliStop() int { } 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. for i := 0; i < 50 && isDaemonRunning(); i++ { @@ -810,9 +808,6 @@ func homeDir() string { return h } -// Pure string formatting for `gitwatchd status`. Same rows and strings as the -// macOS menu, so the habit transfers between machines unchanged. - // 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 @@ -888,8 +883,9 @@ func span(seconds float64) string { } // Autostart = a systemd user unit, the standard way for a per-user daemon to -// survive reboots and headless boots (with lingering). When systemd is absent -// we say so and do nothing: no shell-profile edits, ever. +// survive reboots and headless boots (with lingering). If systemd is absent, +// report this and do nothing (the user should e.g. add `gitwatchd start` to +// a startup script in this case). func unitPath() string { return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") diff --git a/linux/git_driver.go b/linux/git_driver.go index f20322b..0aa2f0c 100644 --- a/linux/git_driver.go +++ b/linux/git_driver.go @@ -7,9 +7,7 @@ import ( "strings" ) -// Thin wrapper around git. No external deps: this plus inotify is the whole -// engine. Behavior mirrors gitwatch: debounced auto-commit, optional push to a -// remote/branch, optional pull --rebase, optional merge-commit guard. +// Thin wrapper around git. // What one auto-commit cycle (or push retry) accomplished. The git commands // and their order are gitwatch's; this only reports the result. @@ -177,15 +175,6 @@ func autoCommit(spec *RepoSpec) Outcome { return pushed } -// The push stage of a cycle, exactly as gitwatch runs it. With -R, first -// `git pull --rebase ` (no branch argument, exit code ignored, no -// abort: a conflict leaves the rebase in progress for the user to resolve, -// and -M is the only guard). Then the push, which upstream runs regardless -// of how the pull went. Split out from autoCommit so a failed push can be -// retried without re-running the commit stage. -// -// Everything below the git calls is reporting only: gitwatch ignores both -// results; we classify them for status. func push(spec *RepoSpec) Outcome { if spec.Remote == "" { return Outcome{Kind: Committed} @@ -216,7 +205,7 @@ func push(spec *RepoSpec) Outcome { // gitwatch's push command: without -b, a bare `push ` (git's // push.default decides); with -b, push `:`, or just -// `` from a detached HEAD. Internal so tests can pin the forms. +// `` from a detached HEAD. func pushArgs(remote string, spec *RepoSpec) []string { if spec.Branch == "" { return []string{"push", remote} @@ -249,9 +238,8 @@ func errorSummary(out string) string { return "unknown git error" } -// Render the current date exactly as upstream does: the -d value passes to -// date(1) verbatim (the user includes the leading +), and a format date(1) -// rejects yields an empty string. +// 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 { From b2b26d5d5d5ba16edcb1fe8a5c3e361822565d3d Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 13:46:33 +0000 Subject: [PATCH 05/19] Apply review feedback to the Linux CLI and git driver 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 --- linux/cli.go | 204 +++++++++++++++++-------------------------- linux/cli_test.go | 5 +- linux/daemon_test.go | 48 +++++++++- linux/git_driver.go | 42 +++++---- linux/utils.go | 40 +++++++++ 5 files changed, 187 insertions(+), 152 deletions(-) create mode 100644 linux/utils.go diff --git a/linux/cli.go b/linux/cli.go index 491216a..ce2faf4 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "math" "os" "os/exec" "path/filepath" @@ -18,8 +17,59 @@ import ( // the two implementations ship as one product and report one version. const version = "0.2.0" -// Tests set this false so CLI calls don't spawn the real daemon. -var spawnsDaemon = true +const usageText = `gitwatchd - daemon that watches git repos and auto-commits changes + +USAGE + gitwatchd [flags] watch a repo + gitwatchd [args] + +EXAMPLES + gitwatchd . watch the current repo, commit locally + gitwatchd -r origin . also push every commit to origin + gitwatchd -r origin -b main -R . two-way sync: fetch commits made on + other machines and rebase yours on top + before each push to origin/main + gitwatchd status see everything being watched + gitwatchd rm blog stop watching, by name or path + +COMMANDS + add [flags] watch a repo (bare ` + "`gitwatchd [flags] `" + ` works too) + rm stop watching a repo + pause stop watching temporarily; the repo stays listed + resume start watching again and commit what piled up + status everything being watched, in the terminal + start, stop start or stop the daemon + autostart [on|off|status] + run the daemon at boot (installs a systemd user unit) + config [path|edit] print the config file path, or open it in your + editor (the one ` + "`git commit`" + ` uses) + help show this help + version print the version + +FLAGS (for add) + -s Wait after the last change before committing, so a + batch of writes lands as one commit. Default: 2. + -r Push to after every commit. Default: no push. + -b Branch to push to. Without it, a plain ` + "`git push `" + ` + decides. Only meaningful together with -r. + -R Before each push, pull commits made elsewhere and rebase + yours on top (` + "`git pull --rebase `" + `). Use with -r + when more than one machine pushes to the same branch. + -m Commit message; %d becomes the timestamp. + Default: "gitwatchd auto-commit (%d)". + -d Format string for that timestamp (see ` + "`man date`" + `). + Default: "+%Y-%m-%d %H:%M:%S". + -x Skip changes whose path matches this regular + expression (e.g. '\.log$' or 'build/'). + -M Skip committing while the repo has a merge in progress. + -f Commit anything already pending as soon as watching + starts (daemon launch, or when the repo is added). + -g Location of the .git directory, if elsewhere (--git-dir). + --paused Keep the repo in the config but don't watch it. This is + what ` + "`gitwatchd pause`" + ` sets. + +The daemon runs in the background and watches every repo listed in ~/.gitwatchd +` type ConfigError struct { Label string // repo name, or the offending line for parse errors @@ -178,10 +228,10 @@ func cliRemove(args []string) int { warn("usage: gitwatchd rm ") return 1 } - needle := args[0] - n := configRemove(needle) + nameOrPath := args[0] + n := configRemove(nameOrPath) if n == 0 { - warn("no watched repo matches " + needle) + warn("no watched repo matches " + nameOrPath) return 1 } ensureDaemonRunning() @@ -189,7 +239,7 @@ func cliRemove(args []string) int { if n == 1 { entries = "entry" } - fmt.Printf("✓ stopped watching %s (%d %s removed)\n", needle, n, entries) + fmt.Printf("✓ stopped watching %s (%d %s removed)\n", nameOrPath, n, entries) return 0 } @@ -202,12 +252,12 @@ func cliSetPaused(args []string, paused bool) int { warn("usage: gitwatchd " + verb + " ") return 1 } - needle := args[0] - n := configSetPaused(needle, paused) + nameOrPath := args[0] + n := configSetPaused(nameOrPath, paused) if n == 0 { known := false for _, s := range configSpecs() { - if configMatches(s, needle) { + if configMatches(s, nameOrPath) { known = true } } @@ -216,17 +266,17 @@ func cliSetPaused(args []string, paused bool) int { if paused { state = "paused" } - warn(needle + " is already " + state) + warn(nameOrPath + " is already " + state) } else { - warn("no watched repo matches " + needle) + warn("no watched repo matches " + nameOrPath) } return 1 } ensureDaemonRunning() if paused { - fmt.Printf("⏸ paused %s (resume with: gitwatchd resume %s)\n", needle, needle) + 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", needle) + fmt.Printf("✓ resumed %s; catching up on anything that changed meanwhile\n", nameOrPath) } return 0 } @@ -389,7 +439,11 @@ func cliStart() int { time.Sleep(100 * time.Millisecond) } if !isDaemonRunning() { - warn("daemon did not come up; check " + daemonLogHint()) + logs := logfilePath() + if unitInstalled() && systemctlPresent() { + logs = "journalctl --user -u gitwatchd" + } + warn("daemon did not come up; check " + logs) return 1 } fmt.Println("✓ daemon started") @@ -441,17 +495,10 @@ func spawnDaemon() error { return cmd.Process.Release() } -func daemonLogHint() string { - if unitInstalled() && systemctlPresent() { - return "journalctl --user -u gitwatchd" - } - return logfilePath() -} - // 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 !spawnsDaemon || os.Getenv("GITWATCHD_NO_SPAWN") != "" || isDaemonRunning() { + if os.Getenv("GITWATCHD_NO_SPAWN") != "" || isDaemonRunning() { return } if unitInstalled() && systemctlPresent() { @@ -481,10 +528,10 @@ func configEnsureExists() { } 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: +# '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) +# (from a terminal, 'gitwatchd .' adds the current repo here for you) ` os.WriteFile(configPath(), []byte(template), 0o644) } @@ -558,11 +605,11 @@ func configAppend(line string) { os.WriteFile(configPath(), []byte(text), 0o644) } -func configMatches(spec *RepoSpec, needle string) bool { - return spec.Path == expandTilde(needle) || spec.Name() == needle || spec.Path == needle +func configMatches(spec *RepoSpec, nameOrPath string) bool { + return spec.Path == expandTilde(nameOrPath) || spec.Name() == nameOrPath || spec.Path == nameOrPath } -func configRemove(needle string) int { +func configRemove(nameOrPath string) int { raw, err := os.ReadFile(configPath()) if err != nil { return 0 @@ -576,7 +623,7 @@ func configRemove(needle string) int { continue } spec, _ := parseRepoSpec(tokenize(line)) - if spec != nil && configMatches(spec, needle) { + if spec != nil && configMatches(spec, nameOrPath) { removed++ continue } @@ -586,11 +633,11 @@ func configRemove(needle string) int { return removed } -// Flip the --paused token on config lines matching `needle` (by full +// Flip the --paused token on config lines matching `nameOrPath` (by full // path or repo name, like remove). Pause lives in the config, not daemon // state, so it survives daemon and machine restarts. Returns the number // of lines changed. -func configSetPaused(needle string, paused bool) int { +func configSetPaused(nameOrPath string, paused bool) int { raw, err := os.ReadFile(configPath()) if err != nil { return 0 @@ -604,7 +651,7 @@ func configSetPaused(needle string, paused bool) int { continue } spec, _ := parseRepoSpec(tokenize(trimmed)) - if spec == nil || !configMatches(spec, needle) { + if spec == nil || !configMatches(spec, nameOrPath) { lines = append(lines, rawLine) continue } @@ -622,9 +669,6 @@ func configSetPaused(needle string, paused bool) int { return changed } -// The pure rewrite behind configSetPaused: if `line` watches `path` and its -// paused state differs, return the line with --paused added (in front) -// or removed; else ok=false for "leave this line alone". func togglingPaused(line string, path string, paused bool) (string, bool) { tokens := tokenize(line) spec, _ := parseRepoSpec(tokens) @@ -647,8 +691,6 @@ func togglingPaused(line string, path string, paused bool) (string, bool) { return strings.Join(quoted, " "), true } -// The tokenizer has no escape syntax: a value with both quote kinds -// cannot round-trip. func quoteIfNeeded(s string) string { if strings.Contains(s, `"`) && !strings.Contains(s, "'") { return "'" + s + "'" @@ -758,7 +800,7 @@ func parseRepoSpec(args []string) (*RepoSpec, string) { spec.GitDir = v } case "-e": - next() // inotify events: accepted, no-op (we watch a fixed gitwatch-like set) + next() // -e accepted for gitwatch compatibility; ignored case "--paused": spec.Paused = true default: @@ -850,38 +892,6 @@ func retryLine(lastTried time.Time, nextRetry *time.Time, now time.Time) string return tried + " · retrying in " + span(dt) } -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))) -} - // Autostart = a systemd user unit, the standard way for a per-user daemon to // survive reboots and headless boots (with lingering). If systemd is absent, // report this and do nothing (the user should e.g. add `gitwatchd start` to @@ -1005,57 +1015,3 @@ func autostartStatus() int { } return 0 } - -const usageText = `gitwatchd - daemon that watches git repos and auto-commits changes - -USAGE - gitwatchd [flags] watch a repo - gitwatchd [args] - -EXAMPLES - gitwatchd . watch the current repo, commit locally - gitwatchd -r origin . also push every commit to origin - gitwatchd -r origin -b main -R . two-way sync: fetch commits made on - other machines and rebase yours on top - before each push to origin/main - gitwatchd status see everything being watched - gitwatchd rm blog stop watching, by name or path - -COMMANDS - add [flags] watch a repo (bare ` + "`gitwatchd [flags] `" + ` works too) - rm stop watching a repo - pause stop watching temporarily; the repo stays listed - resume start watching again and commit what piled up - status everything being watched, in the terminal - start, stop start or stop the daemon - autostart [on|off|status] - run the daemon at boot (installs a systemd user unit) - config [path|edit] print the config file path, or open it in your - editor (the one ` + "`git commit`" + ` uses) - help show this help - version print the version - -FLAGS (for add) - -s Wait after the last change before committing, so a - batch of writes lands as one commit. Default: 2. - -r Push to after every commit. Default: no push. - -b Branch to push to. Without it, a plain ` + "`git push `" + ` - decides. Only meaningful together with -r. - -R Before each push, pull commits made elsewhere and rebase - yours on top (` + "`git pull --rebase `" + `). Use with -r - when more than one machine pushes to the same branch. - -m Commit message; %d becomes the timestamp. - Default: "gitwatchd auto-commit (%d)". - -d Format string for that timestamp (see ` + "`man date`" + `). - Default: "+%Y-%m-%d %H:%M:%S". - -x Skip changes whose path matches this regular - expression (e.g. '\.log$' or 'build/'). - -M Skip committing while the repo has a merge in progress. - -f Commit anything already pending as soon as watching - starts (daemon launch, or when the repo is added). - -g Location of the .git directory, if elsewhere (--git-dir). - --paused Keep the repo in the config but don't watch it. This is - what ` + "`gitwatchd pause`" + ` sets. - -The daemon runs in the background and watches every repo listed in ~/.gitwatchd -` diff --git a/linux/cli_test.go b/linux/cli_test.go index 89594f7..0daea7e 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -13,8 +13,7 @@ import ( func withTemporaryConfig(t *testing.T) { t.Helper() - spawnsDaemon = false - t.Cleanup(func() { spawnsDaemon = true }) + 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")) @@ -485,7 +484,7 @@ func TestErrorLabelFlagsTheRow(t *testing.T) { } func TestOutcomeLabels(t *testing.T) { - cases := map[OutcomeKind]string{ + cases := map[CommitOutcome]string{ PushFailed: "push failing", RebaseConflict: "rebase conflict", CommitFailed: "commit failing", diff --git a/linux/daemon_test.go b/linux/daemon_test.go index 07529d2..18a0f7d 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -136,6 +136,43 @@ func waitForDaemonPickup(t *testing.T, repo *testRepo, change func()) { 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) + } +} + func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { if testBinary == "" { t.Fatal("test binary did not build") @@ -187,12 +224,17 @@ func lookPathIn(path, tool string) (string, error) { return "", os.ErrNotExist } -func killDaemonIfRunning(home string) { +func daemonPidIn(home string) int { raw, err := os.ReadFile(filepath.Join(home, "state", "gitwatchd.pid")) if err != nil { - return + return 0 } - if pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))); pid > 0 { + pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) + return pid +} + +func killDaemonIfRunning(home string) { + if pid := daemonPidIn(home); pid > 0 { syscall.Kill(pid, syscall.SIGKILL) } } diff --git a/linux/git_driver.go b/linux/git_driver.go index 0aa2f0c..ce16db7 100644 --- a/linux/git_driver.go +++ b/linux/git_driver.go @@ -9,22 +9,21 @@ import ( // Thin wrapper around git. -// What one auto-commit cycle (or push retry) accomplished. The git commands -// and their order are gitwatch's; this only reports the result. -type OutcomeKind int +// What one auto-commit cycle (or push retry) accomplished. +type CommitOutcome int const ( - Clean OutcomeKind = 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 + 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 OutcomeKind + Kind CommitOutcome Detail string } @@ -133,24 +132,21 @@ func autoCommit(spec *RepoSpec) Outcome { return Outcome{Kind: Clean} } - // Upstream builds the message before `git add`, and so do we: the -c/-C - // message command (not ported yet) has to see the unstaged tree. - // Upstream's ${COMMITMSG/\%d/...}: the date splices into the first %d only. + // 1. Build the message before add: upstream's order, and -c/-C (not + // ported yet) must see the unstaged tree. Upstream's ${COMMITMSG/\%d/...} + // splices the date into the first %d only. msg := strings.Replace(spec.Message, "%d", formattedDate(spec.DateFormat), 1) - // Upstream's GIT_ADD_ARGS: "--all ." scoped to the target directory, - // or just the file for a file target. + // 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) - code, out := gitRun([]string{"commit", "-m", msg}, dir, spec.GitDir) - // gitwatch runs the pull (-R) and the push unconditionally after the - // commit, whether or not it succeeded, so a repo whose commit a hook - // blocks still pushes what is already committed. commitOutcome is - // reporting only. + // 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. @@ -163,8 +159,10 @@ func autoCommit(spec *RepoSpec) Outcome { } } - // No remote: never call push(), whose no-remote return would mask a - // commit failure. + // 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 } 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))) +} From f4807a043613b60b7fa10e5190afee056cb53034 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 13:47:42 +0000 Subject: [PATCH 06/19] Move the parity tests into gitwatch_parity_test.go 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 --- linux/git_driver_test.go | 308 --------------------------------- linux/gitwatch_parity_test.go | 316 ++++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+), 308 deletions(-) create mode 100644 linux/gitwatch_parity_test.go diff --git a/linux/git_driver_test.go b/linux/git_driver_test.go index 0067a02..b05d988 100644 --- a/linux/git_driver_test.go +++ b/linux/git_driver_test.go @@ -1,15 +1,11 @@ package main import ( - "context" - "fmt" "os" - "os/exec" "path/filepath" "strconv" "strings" "testing" - "time" ) type testRepo struct { @@ -514,307 +510,3 @@ func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { t.Error("the conflicted rebase is left in progress") } } - -// 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 the macOS test -// suite; both implementations measure themselves against that same copy. -func gitwatchScript(t *testing.T) string { - t.Helper() - wd, _ := os.Getwd() - script := filepath.Join(wd, "..", "macos", "Tests", "Reference", "gitwatch.sh") - if _, err := os.Stat(script); err != nil { - t.Fatalf("vendored gitwatch.sh not found at %s", script) - } - return script -} - -// A watcher stub that exits immediately: gitwatch runs its -f startup -// commit, the watch pipe hits EOF, and the script terminates. -func stubWatcher(t *testing.T) string { - t.Helper() - stub := filepath.Join(t.TempDir(), "inotifywait") - if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - return stub -} - -// Run upstream gitwatch for exactly one commit cycle on `target`. -func runOneCycle(t *testing.T, flags []string, target string) { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - args := append([]string{gitwatchScript(t), "-f"}, flags...) - args = append(args, target) - cmd := exec.CommandContext(ctx, "bash", args...) - cmd.Env = append(os.Environ(), "GW_INW_BIN="+stubWatcher(t)) - cmd.Run() // fire-and-forget, like gitwatch itself -} - -// Build two identical worlds, run the model on one and our engine on the -// other with the same flags, and return both fingerprints. -func twins(t *testing.T, flags []string, withRemote bool, targetSuffix string, - setup func(*testRepo, *bareRemote)) (model, ours repoState) { - t.Helper() - target := func(repo *testRepo) string { - if targetSuffix != "" { - return filepath.Join(repo.path, targetSuffix) - } - return repo.path - } - - a := newTestRepo(t) - var ra *bareRemote - if withRemote { - ra = a.addOrigin() - } - setup(a, ra) - runOneCycle(t, flags, target(a)) - model = stateOf(a, ra) - - b := newTestRepo(t) - var rb *bareRemote - if withRemote { - rb = b.addOrigin() - } - setup(b, rb) - spec, errMsg := parseRepoSpec(append(flags, target(b))) - if spec == nil { - t.Fatal(errMsg) - } - autoCommit(spec) - ours = stateOf(b, rb) - return model, ours -} - -func expectParity(t *testing.T, model, ours repoState) { - t.Helper() - if model != ours { - t.Errorf("state diverged\n gitwatch: %v\n ours: %v", model, ours) - } -} - -// Plain-git scenario builders (no engine involvement). -func seed(repo *testRepo) { - repo.write("seed.txt", "seed\n") - repo.git("add", "-A") - repo.git("commit", "-q", "-m", "seed") -} - -func seedAndPush(repo *testRepo) { - seed(repo) - repo.git("push", "-q", "origin", "main") - repo.trackOrigin() -} - -func colleaguePushes(t *testing.T, remote *bareRemote, file, message string) { - colleague := newCloneOf(t, remote) - colleague.write(file, "from the other machine\n") - colleague.git("add", "-A") - colleague.git("commit", "-q", "-m", message) - colleague.git("push", "-q", "origin", "main") -} - -func TestParityCleanRepo(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { - seed(repo) - }) - expectParity(t, model, ours) - if ours.commitCount != 1 { - t.Errorf("neither side commits anything: %v", ours) - } -} - -func TestParityPlainCommit(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { - seed(repo) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 || ours.lastMessage != "cycle" || ours.pendingChanges != 0 { - t.Errorf("same commit, same message, clean tree afterwards: %v", ours) - } -} - -// Sharp corner, kept for upstream parity: -d passes to date(1) raw and only -// the first %d is spliced. The year-only format keeps the spliced date -// identical across the two runs, so the fingerprints compare exactly. -func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { - model, ours := twins(t, []string{"-m", "at %d then %d", "-d", "+%Y"}, false, "", - func(repo *testRepo, _ *bareRemote) { - seed(repo) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if !strings.HasPrefix(ours.lastMessage, "at 2") || !strings.HasSuffix(ours.lastMessage, " then %d") { - t.Errorf("got %q", ours.lastMessage) - } -} - -func TestParityPushToRemote(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", - func(repo *testRepo, _ *bareRemote) { - seedAndPush(repo) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.remoteCommitCount != 2 || ours.remoteLastMessage != "cycle" { - t.Errorf("both push the commit: %v", ours) - } -} - -func TestParityUnreachableRemote(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", - func(repo *testRepo, _ *bareRemote) { - seed(repo) - repo.setOriginURL(filepath.Join(t.TempDir(), "gone.git")) - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 { - t.Error("fire-and-forget: the commit still happens") - } -} - -// Upstream runs the pull and the push after `git commit` whether or not the -// commit succeeded, so a repo whose commit a hook blocks still pushes what is -// already committed. The seed here is deliberately left unpushed, which is what -// lets the two worlds disagree if we ever short-circuit on a failed commit. -func TestParityFailedCommitStillPushes(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", - func(repo *testRepo, _ *bareRemote) { - seed(repo) // committed locally, never pushed - repo.installFailingPreCommitHook("lint says no") - repo.write("notes.txt", "hello\n") - }) - expectParity(t, model, ours) - if ours.remoteCommitCount != 1 { - t.Errorf("the already-committed seed reaches the remote on both sides: %v", ours) - } - if ours.commitCount != 1 || ours.pendingChanges == 0 { - t.Errorf("the hook blocked the new commit on both sides: %v", ours) - } -} - -func TestParityMergeGuard(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-M"}, false, "", func(repo *testRepo, _ *bareRemote) { - repo.conflictedMerge() - }) - expectParity(t, model, ours) - if !ours.midMerge { - t.Error("the merge is left untouched on both sides") - } -} - -func TestParityMergeCommittedWithoutGuard(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { - repo.conflictedMerge() - }) - expectParity(t, model, ours) - if ours.midMerge { - t.Error("the cycle concluded the merge on both sides") - } -} - -func TestParityRebaseHappyPath(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", - func(repo *testRepo, remote *bareRemote) { - seedAndPush(repo) - colleaguePushes(t, remote, "theirs.txt", "made elsewhere") - repo.write("ours.txt", "made here\n") - }) - expectParity(t, model, ours) - if ours.remoteCommitCount != 3 { - t.Error("seed, theirs, ours: one linear history") - } - if ours.remoteLastMessage != "cycle" { - t.Errorf("got %q", ours.remoteLastMessage) - } -} - -func TestParitySubdirectoryTarget(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "sub", func(repo *testRepo, _ *bareRemote) { - repo.write("sub/inner.txt", "v1\n") - repo.git("add", "-A") - repo.git("commit", "-q", "-m", "seed") - repo.write("sub/inner.txt", "v2\n") - repo.write("outer.txt", "left alone\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 || ours.pendingChanges != 1 { - t.Errorf("outer.txt stays uncommitted on both sides: %v", ours) - } -} - -func TestParityFileTarget(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { - repo.write("a.txt", "v1\n") - repo.git("add", "-A") - repo.git("commit", "-q", "-m", "seed") - repo.write("a.txt", "v2\n") - repo.write("b.txt", "left alone\n") - }) - expectParity(t, model, ours) - if ours.commitCount != 2 || ours.pendingChanges != 1 { - t.Errorf("b.txt stays uncommitted on both sides: %v", ours) - } -} - -func TestParityRebaseConflictLeftInProgress(t *testing.T) { - model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", - func(repo *testRepo, remote *bareRemote) { - seedAndPush(repo) - colleaguePushes(t, remote, "seed.txt", "conflicting edit") - repo.write("seed.txt", "our conflicting edit\n") - }) - expectParity(t, model, ours) - if !ours.midRebase { - t.Error("both sides stop mid-rebase; -M is the only guard") - } -} diff --git a/linux/gitwatch_parity_test.go b/linux/gitwatch_parity_test.go new file mode 100644 index 0000000..a70f235 --- /dev/null +++ b/linux/gitwatch_parity_test.go @@ -0,0 +1,316 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Differential parity tests: the same scenario runs through upstream +// gitwatch.sh (the model) and through our engine, each on its own +// identically-built world, and the observable git state afterwards must be +// identical. Scenario setup uses only plain git commands, so neither +// implementation touches the world until the measured cycle. +// +// Determinism: gitwatch's -f performs one commit cycle before entering its +// watch loop, and GW_INW_BIN lets us point the watcher at a stub that exits +// immediately, so the script does exactly one cycle and terminates. Every +// scenario passes an explicit -m without %d, since default messages and +// timestamps intentionally differ. + +// Everything a user could observe about a world after one cycle. +type repoState struct { + commitCount int + lastMessage string + pendingChanges int + branch string + midMerge bool + midRebase bool + remoteCommitCount int // -1 when the scenario has no remote + remoteLastMessage string +} + +func (s repoState) String() string { + return fmt.Sprintf("commits=%d last=%q pending=%d branch=%s midMerge=%v midRebase=%v remote(commits=%d last=%q)", + s.commitCount, s.lastMessage, s.pendingChanges, s.branch, + s.midMerge, s.midRebase, s.remoteCommitCount, s.remoteLastMessage) +} + +func stateOf(repo *testRepo, remote *bareRemote) repoState { + s := repoState{ + commitCount: repo.commitCount(), + lastMessage: repo.lastMessage(), + pendingChanges: pendingCount(repo.path, ""), + branch: currentBranch(repo.path, ""), + midMerge: repo.midMerge(), + midRebase: repo.midRebase(), + remoteCommitCount: -1, + } + if remote != nil { + s.remoteCommitCount = remote.commitCount() + s.remoteLastMessage = remote.lastMessage() + } + return s +} + +// The upstream script, vendored once for the whole repo in the macOS test +// suite; both implementations measure themselves against that same copy. +func gitwatchScript(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + script := filepath.Join(wd, "..", "macos", "Tests", "Reference", "gitwatch.sh") + if _, err := os.Stat(script); err != nil { + t.Fatalf("vendored gitwatch.sh not found at %s", script) + } + return script +} + +// A watcher stub that exits immediately: gitwatch runs its -f startup +// commit, the watch pipe hits EOF, and the script terminates. +func stubWatcher(t *testing.T) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "inotifywait") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + return stub +} + +// Run upstream gitwatch for exactly one commit cycle on `target`. +func runOneCycle(t *testing.T, flags []string, target string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + args := append([]string{gitwatchScript(t), "-f"}, flags...) + args = append(args, target) + cmd := exec.CommandContext(ctx, "bash", args...) + cmd.Env = append(os.Environ(), "GW_INW_BIN="+stubWatcher(t)) + cmd.Run() // fire-and-forget, like gitwatch itself +} + +// Build two identical worlds, run the model on one and our engine on the +// other with the same flags, and return both fingerprints. +func twins(t *testing.T, flags []string, withRemote bool, targetSuffix string, + setup func(*testRepo, *bareRemote)) (model, ours repoState) { + t.Helper() + target := func(repo *testRepo) string { + if targetSuffix != "" { + return filepath.Join(repo.path, targetSuffix) + } + return repo.path + } + + a := newTestRepo(t) + var ra *bareRemote + if withRemote { + ra = a.addOrigin() + } + setup(a, ra) + runOneCycle(t, flags, target(a)) + model = stateOf(a, ra) + + b := newTestRepo(t) + var rb *bareRemote + if withRemote { + rb = b.addOrigin() + } + setup(b, rb) + spec, errMsg := parseRepoSpec(append(flags, target(b))) + if spec == nil { + t.Fatal(errMsg) + } + autoCommit(spec) + ours = stateOf(b, rb) + return model, ours +} + +func expectParity(t *testing.T, model, ours repoState) { + t.Helper() + if model != ours { + t.Errorf("state diverged\n gitwatch: %v\n ours: %v", model, ours) + } +} + +// Plain-git scenario builders (no engine involvement). +func seed(repo *testRepo) { + repo.write("seed.txt", "seed\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") +} + +func seedAndPush(repo *testRepo) { + seed(repo) + repo.git("push", "-q", "origin", "main") + repo.trackOrigin() +} + +func colleaguePushes(t *testing.T, remote *bareRemote, file, message string) { + colleague := newCloneOf(t, remote) + colleague.write(file, "from the other machine\n") + colleague.git("add", "-A") + colleague.git("commit", "-q", "-m", message) + colleague.git("push", "-q", "origin", "main") +} + +func TestParityCleanRepo(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + }) + expectParity(t, model, ours) + if ours.commitCount != 1 { + t.Errorf("neither side commits anything: %v", ours) + } +} + +func TestParityPlainCommit(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.lastMessage != "cycle" || ours.pendingChanges != 0 { + t.Errorf("same commit, same message, clean tree afterwards: %v", ours) + } +} + +// Sharp corner, kept for upstream parity: -d passes to date(1) raw and only +// the first %d is spliced. The year-only format keeps the spliced date +// identical across the two runs, so the fingerprints compare exactly. +func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { + model, ours := twins(t, []string{"-m", "at %d then %d", "-d", "+%Y"}, false, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if !strings.HasPrefix(ours.lastMessage, "at 2") || !strings.HasSuffix(ours.lastMessage, " then %d") { + t.Errorf("got %q", ours.lastMessage) + } +} + +func TestParityPushToRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seedAndPush(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 2 || ours.remoteLastMessage != "cycle" { + t.Errorf("both push the commit: %v", ours) + } +} + +func TestParityUnreachableRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.setOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 { + t.Error("fire-and-forget: the commit still happens") + } +} + +// Upstream runs the pull and the push after `git commit` whether or not the +// commit succeeded, so a repo whose commit a hook blocks still pushes what is +// already committed. The seed here is deliberately left unpushed, which is what +// lets the two worlds disagree if we ever short-circuit on a failed commit. +func TestParityFailedCommitStillPushes(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) // committed locally, never pushed + repo.installFailingPreCommitHook("lint says no") + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 1 { + t.Errorf("the already-committed seed reaches the remote on both sides: %v", ours) + } + if ours.commitCount != 1 || ours.pendingChanges == 0 { + t.Errorf("the hook blocked the new commit on both sides: %v", ours) + } +} + +func TestParityMergeGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-M"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if !ours.midMerge { + t.Error("the merge is left untouched on both sides") + } +} + +func TestParityMergeCommittedWithoutGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if ours.midMerge { + t.Error("the cycle concluded the merge on both sides") + } +} + +func TestParityRebaseHappyPath(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "theirs.txt", "made elsewhere") + repo.write("ours.txt", "made here\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 3 { + t.Error("seed, theirs, ours: one linear history") + } + if ours.remoteLastMessage != "cycle" { + t.Errorf("got %q", ours.remoteLastMessage) + } +} + +func TestParitySubdirectoryTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "sub", func(repo *testRepo, _ *bareRemote) { + repo.write("sub/inner.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("outer.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityFileTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { + repo.write("a.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("b.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityRebaseConflictLeftInProgress(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "seed.txt", "conflicting edit") + repo.write("seed.txt", "our conflicting edit\n") + }) + expectParity(t, model, ours) + if !ours.midRebase { + t.Error("both sides stop mid-rebase; -M is the only guard") + } +} From 9a73ea3986609dbe57c3ca81a7adf87073e1c850 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 13:48:38 +0000 Subject: [PATCH 07/19] Move the gitwatch reference script to a top-level oracle/ 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 --- linux/gitwatch_parity_test.go | 6 +++--- macos/Tests/ParityTests.swift | 2 +- {macos/Tests/Reference => oracle}/README.md | 7 ++++--- {macos/Tests/Reference => oracle}/gitwatch.sh | 0 4 files changed, 8 insertions(+), 7 deletions(-) rename {macos/Tests/Reference => oracle}/README.md (63%) rename {macos/Tests/Reference => oracle}/gitwatch.sh (100%) diff --git a/linux/gitwatch_parity_test.go b/linux/gitwatch_parity_test.go index a70f235..d7692bb 100644 --- a/linux/gitwatch_parity_test.go +++ b/linux/gitwatch_parity_test.go @@ -58,12 +58,12 @@ func stateOf(repo *testRepo, remote *bareRemote) repoState { return s } -// The upstream script, vendored once for the whole repo in the macOS test -// suite; both implementations measure themselves against that same copy. +// 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, "..", "macos", "Tests", "Reference", "gitwatch.sh") + script := filepath.Join(wd, "..", "oracle", "gitwatch.sh") if _, err := os.Stat(script); err != nil { t.Fatalf("vendored gitwatch.sh not found at %s", script) } diff --git a/macos/Tests/ParityTests.swift b/macos/Tests/ParityTests.swift index b0016ac..8e8f6ad 100644 --- a/macos/Tests/ParityTests.swift +++ b/macos/Tests/ParityTests.swift @@ -49,7 +49,7 @@ enum GitwatchReference { /// tests work regardless of the working directory. static let script = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() - .appendingPathComponent("Reference/gitwatch.sh").path + .appendingPathComponent("../../oracle/gitwatch.sh").path /// A watcher stub that exits immediately: gitwatch runs its -f startup /// commit, the watch pipe hits EOF, and the script terminates. diff --git a/macos/Tests/Reference/README.md b/oracle/README.md similarity index 63% rename from macos/Tests/Reference/README.md rename to oracle/README.md index 5686691..0a0d239 100644 --- a/macos/Tests/Reference/README.md +++ b/oracle/README.md @@ -1,9 +1,10 @@ # 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`): the same +scenario runs through this script and through our engine, and the resulting git +state must be identical. - Source: https://raw.githubusercontent.com/gitwatch/gitwatch/master/gitwatch.sh - Fetched: 2026-07-18 diff --git a/macos/Tests/Reference/gitwatch.sh b/oracle/gitwatch.sh similarity index 100% rename from macos/Tests/Reference/gitwatch.sh rename to oracle/gitwatch.sh From 1eb3ffd1932ebf7601151f03c70b046d34652f5d Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 13:50:26 +0000 Subject: [PATCH 08/19] Trim comments in the daemon Co-Authored-By: Claude Fable 5 --- linux/daemon.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/linux/daemon.go b/linux/daemon.go index ae941d0..f53e664 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -70,10 +70,6 @@ type repoWatcher struct { logf func(format string, args ...any) } -// The headless daemon: one process, one flock-guarded pidfile, a watcher per -// configured repo, and a live reload whenever ~/.gitwatchd changes. Under -// systemd its stdout/stderr go to the journal; that is the log. - func runDaemon() int { lock, err := acquireDaemonLock() if err != nil { @@ -225,7 +221,6 @@ func uint32frombytes(b []byte) uint32 { } // Single-instance guard: the daemon holds an exclusive flock on the pidfile -// for its whole life, so liveness checks can't be fooled by stale pids. func acquireDaemonLock() (*os.File, error) { os.MkdirAll(stateDir(), 0o755) f, err := os.OpenFile(pidfilePath(), os.O_RDWR|os.O_CREATE, 0o644) From 937a0598b21b0fdd701c281dbd83094a2c9d0d0d Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 13:59:37 +0000 Subject: [PATCH 09/19] Make the Linux daemon file read top to bottom 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 --- linux/cli.go | 2 +- linux/daemon.go | 561 +++++++++++++++++++++---------------------- linux/daemon_test.go | 4 +- 3 files changed, 277 insertions(+), 290 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index ce2faf4..75de51f 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -297,7 +297,7 @@ func cliStatus() int { } daemonErrors := map[string]RepoStatus{} if running { - daemonErrors = stateErrors() + daemonErrors = daemonState.statuses() } repos := "repos" if len(specs) == 1 { diff --git a/linux/daemon.go b/linux/daemon.go index f53e664..e13cf4a 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -17,6 +17,23 @@ import ( "unsafe" ) +// Where the daemon keeps its runtime files. GITWATCHD_STATE_DIR overrides the +// lot, which is how the 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 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 | @@ -24,8 +41,7 @@ const watchMask = syscall.IN_CLOSE_WRITE | syscall.IN_MOVED_FROM | syscall.IN_MO 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. Stored as one JSON file rewritten -// atomically; the daemon is the only writer. +// `gitwatchd status` is a view over it. type RepoStatus struct { ErrorLabel string `json:"errorLabel"` Detail string `json:"detail,omitempty"` @@ -34,40 +50,74 @@ type RepoStatus struct { NextRetry *int64 `json:"nextRetry,omitempty"` } -// Recursive inotify watcher for one repo, with a debounce (gitwatch's -s) and -// .git-churn filtering so our own commits don't retrigger the watcher. Also -// honors gitwatch's -x exclude patterns. -// -// On top of the gitwatch cycle it keeps an additive reliability layer: per-repo -// error state and automatic push retries with backoff. The layer never changes -// what a commit cycle does; it only re-runs the push stage of one that failed. - -type repoError struct { - outcome Outcome - lastAttempt time.Time - attempts int // consecutive failures - nextRetry *time.Time // nil: no auto retry, waits for a change +// The state file: one JSON object of RepoStatus keyed by repo path. The daemon +// is the only writer, every gitwatchd process a reader. +type stateFile struct { + mu sync.Mutex // one whole-file rewrite at a time } -type repoWatcher struct { - spec *RepoSpec +var daemonState stateFile - fd int - wds map[int]string // watch descriptor -> directory path - mu sync.Mutex // guards wds and watchErr +// Takes no lock: writes land by rename, so a reader always sees one whole +// version of the file. +func (s *stateFile) statuses() map[string]RepoStatus { + raw, err := os.ReadFile(statePath()) + if err != nil { + return map[string]RepoStatus{} + } + var out map[string]RepoStatus + if json.Unmarshal(raw, &out) != nil || out == nil { + return map[string]RepoStatus{} + } + return out +} - events chan struct{} - flush chan struct{} - stop chan struct{} - done chan struct{} +// Record one repo's error state, or clear it when status is nil. +func (s *stateFile) setStatus(repoPath string, status *RepoStatus) { + s.mu.Lock() + defer s.mu.Unlock() + statuses := s.statuses() + if status == nil { + delete(statuses, repoPath) + } else { + statuses[repoPath] = *status + } + s.write(statuses) +} - lastError *repoError - watchErr string // e.g. the inotify watch limit; surfaced, never fatal +// Forget the repos that have left the config. +func (s *stateFile) keepOnly(watched map[string]bool) { + s.mu.Lock() + defer s.mu.Unlock() + statuses := s.statuses() + for path := range statuses { + if !watched[path] { + delete(statuses, path) + } + } + s.write(statuses) +} - // Called after every cycle with the repo path and its error state - // (nil while healthy); the daemon publishes it for `status`. - onState func(path string, status *RepoStatus) - logf func(format string, args ...any) +func (s *stateFile) write(statuses map[string]RepoStatus) { + os.MkdirAll(stateDir(), 0o755) + raw, err := json.Marshal(statuses) + if err != nil { + return + } + // Write beside the file and rename over it, so no reader ever sees a + // half-written state. + tmp := statePath() + ".tmp" + if os.WriteFile(tmp, raw, 0o644) == nil { + os.Rename(tmp, statePath()) + } +} + +// The running daemon: one watcher per watchable repo in the config, kept in +// step with the 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 { @@ -78,146 +128,143 @@ func runDaemon() int { } defer lock.Close() - logf := func(format string, args ...any) { - fmt.Printf(format+"\n", args...) + d := &daemon{ + watchers: map[string]*repoWatcher{}, + previousSpecs: map[string]*RepoSpec{}, + logf: func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + }, } - logf("gitwatchd %s: watching config %s", version, configPath()) - - watchers := map[string]*repoWatcher{} // by repo path - prevSpecs := map[string]*RepoSpec{} // includes paused entries + d.logf("gitwatchd %s: watching config %s", version, configPath()) + d.reloadConfig() - reload := func() { - specs, errs := configLoad() - for _, e := range errs { - logf("config: %s: %s", e.Label, e.Reason) - } - - desired := map[string]*RepoSpec{} - for _, s := range specs { - desired[s.Path] = s - } - - keep := map[string]bool{} - for path := range desired { - keep[path] = true - } - statePrune(keep) - - for path, w := range watchers { - s, wanted := desired[path] - if wanted && !s.Paused && s.Raw == w.spec.Raw { - continue + 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: } - w.stopWatching() - delete(watchers, path) - logf("stopped watching %s", w.spec.Name()) + d.reloadConfig() } + }() - for path, s := range desired { - if s.Paused { - continue - } - if _, running := watchers[path]; running { - continue - } - w, err := newRepoWatcher(s, stateSet, logf) - if err != nil { - logf("could not watch %s: %v", s.Name(), err) - continue - } - watchers[path] = w - w.publish() // surface watch-registration problems immediately - logf("watching %s (%s)", s.Name(), s.Path) - - prev, existed := prevSpecs[path] - resumed := existed && prev.Paused - if s.CommitOnStart || resumed { - w.flushNow() // -f, or a resume catching up on what piled up - } - } + 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 +} - prevSpecs = desired +// Bring the watchers in line with the config: stop the ones whose repo left, +// was paused or had its flags edited, and start one for every watchable repo +// that has none. +func (d *daemon) reloadConfig() { + specs, errs := configLoad() + for _, e := range errs { + d.logf("config: %s: %s", e.Label, e.Reason) } - reload() + desired := map[string]*RepoSpec{} + watched := map[string]bool{} + for _, s := range specs { + desired[s.Path] = s + watched[s.Path] = true + } + daemonState.keepOnly(watched) - configEvents := make(chan struct{}, 1) - go watchConfigFile(configEvents) - go func() { - for range configEvents { - time.Sleep(300 * time.Millisecond) // let editors finish the save - drain(configEvents) - reload() + for path, w := range d.watchers { + s, wanted := desired[path] + if wanted && !s.Paused && s.Raw == w.spec.Raw { + continue } - }() - - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - <-sig - for _, w := range watchers { w.stopWatching() + delete(d.watchers, path) + d.logf("stopped watching %s", w.spec.Name()) } - logf("gitwatchd stopped") - return 0 -} -func drain(ch chan struct{}) { - for { - select { - case <-ch: - default: - return + for path, s := range desired { + if s.Paused { + continue + } + if _, running := d.watchers[path]; running { + continue + } + w, err := newRepoWatcher(s, daemonState.setStatus, 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 config file's directory and signal when the file changes -// (editors typically replace the file, so watching the path itself breaks). -func watchConfigFile(events chan struct{}) { +// Watch the config file's directory and report every change to the file +// itself (editors typically replace the file, so watching the path breaks). +func watchConfigFile(changed chan struct{}) { fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) if err != nil { return } - dir := filepath.Dir(configPath()) - base := filepath.Base(configPath()) - if _, err := syscall.InotifyAddWatch(fd, dir, watchMask); err != nil { + 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) + } + }) +} + +// Read from an inotify fd until it closes, calling onEvent for every event. +// Each event is a fixed-size header followed by Len bytes of NUL-padded name, +// and the read buffer has to 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 + return // the fd was closed } - if inotifyNamesContain(buf[:n], base) { - select { - case events <- struct{}{}: - default: + 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) } } } -func inotifyNamesContain(buf []byte, want string) bool { - offset := 0 - for offset+syscall.SizeofInotifyEvent <= len(buf) { - lenField := int(uint32frombytes(buf[offset+12 : offset+16])) - name := "" - if lenField > 0 { - raw := buf[offset+syscall.SizeofInotifyEvent : offset+syscall.SizeofInotifyEvent+lenField] - name = strings.TrimRight(string(raw), "\x00") - } - if name == want { - return true - } - offset += syscall.SizeofInotifyEvent + lenField +// Ask the receiver to run once more. A wakeup already queued covers whatever +// just happened (the receiver reads current state), so a full channel is a +// success, not a reason to block. +func queueWakeup(ch chan struct{}) { + select { + case ch <- struct{}{}: + default: } - return false -} - -func uint32frombytes(b []byte) uint32 { - return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 } // Single-instance guard: the daemon holds an exclusive flock on the pidfile @@ -258,60 +305,96 @@ func daemonPid() int { return pid } -func newRepoWatcher(spec *RepoSpec, onState func(string, *RepoStatus), +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, with a debounce (gitwatch's -s) and +// .git-churn filtering so our own commits don't retrigger the watcher. Also +// honors gitwatch's -x exclude patterns. +// +// On top of the gitwatch cycle it keeps an additive reliability layer: per-repo +// error state and automatic push retries with backoff. The layer 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 with the repo path and its error state + // (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, - fd: fd, - wds: map[int]string{}, - events: make(chan struct{}, 64), - flush: make(chan struct{}, 1), - stop: make(chan struct{}), - done: make(chan struct{}), - onState: onState, - logf: logf, + 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.addWatchTracked(filepath.Dir(spec.Path)) + w.watchDirectory(filepath.Dir(spec.Path)) } else { - w.addRecursive(spec.Path) + w.watchTree(spec.Path) } - go w.readLoop() - go w.run() + 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) addWatchTracked(dir string) { - wd, err := syscall.InotifyAddWatch(w.fd, dir, watchMask) +func (w *repoWatcher) watchDirectory(dir string) { + wd, err := syscall.InotifyAddWatch(w.inotifyFD, dir, watchMask) if err != nil { - w.addWatchError(dir, err) + w.recordWatchFailure(dir, err) return } - w.mu.Lock() - w.wds[wd] = dir - w.mu.Unlock() + w.watchMu.Lock() + w.dirByWatchDescriptor[wd] = dir + w.watchMu.Unlock() } -func (w *repoWatcher) addWatchError(dir string, err error) { - w.mu.Lock() - defer w.mu.Unlock() +func (w *repoWatcher) recordWatchFailure(dir string, err error) { + w.watchMu.Lock() + defer w.watchMu.Unlock() if err == syscall.ENOSPC { - w.watchErr = "inotify watch limit reached: raise fs.inotify.max_user_watches " + + 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.watchErr = "watch failed for " + dir + ": " + err.Error() + w.watchFailure = "watch failed for " + dir + ": " + err.Error() } } -func (w *repoWatcher) addRecursive(root string) { +// 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 @@ -319,43 +402,22 @@ func (w *repoWatcher) addRecursive(root string) { if d.Name() == ".git" { return filepath.SkipDir } - w.addWatchTracked(path) + w.watchDirectory(path) return nil }) } -func (w *repoWatcher) readLoop() { - buf := make([]byte, 64*1024) - for { - n, err := syscall.Read(w.fd, buf) - if err != nil || n <= 0 { - return // fd closed by Stop - } - offset := 0 - for offset+syscall.SizeofInotifyEvent <= n { - raw := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset])) - name := "" - if raw.Len > 0 { - b := buf[offset+syscall.SizeofInotifyEvent : offset+syscall.SizeofInotifyEvent+int(raw.Len)] - name = string(bytes.TrimRight(b, "\x00")) - } - offset += syscall.SizeofInotifyEvent + int(raw.Len) - w.handleEvent(int(raw.Wd), raw.Mask, name) - } - } -} - -func (w *repoWatcher) handleEvent(wd int, mask uint32, name string) { +func (w *repoWatcher) handleInotifyEvent(watchDescriptor int, mask uint32, name string) { if mask&syscall.IN_Q_OVERFLOW != 0 { - w.signal() // events were dropped; a cycle will catch whatever changed + queueWakeup(w.fileChanges) // events were dropped; a cycle will catch whatever changed return } - w.mu.Lock() - dir, known := w.wds[wd] + w.watchMu.Lock() + dir, known := w.dirByWatchDescriptor[watchDescriptor] if mask&syscall.IN_IGNORED != 0 { - delete(w.wds, wd) + delete(w.dirByWatchDescriptor, watchDescriptor) } - w.mu.Unlock() + w.watchMu.Unlock() if !known { return } @@ -370,7 +432,7 @@ func (w *repoWatcher) handleEvent(wd int, mask uint32, name string) { if path != w.spec.Path { return } - w.signal() + queueWakeup(w.fileChanges) return } @@ -381,28 +443,18 @@ func (w *repoWatcher) handleEvent(wd int, mask uint32, name string) { return } if mask&syscall.IN_ISDIR != 0 && mask&(syscall.IN_CREATE|syscall.IN_MOVED_TO) != 0 { - w.addRecursive(path) - } - w.signal() -} - -func (w *repoWatcher) signal() { - select { - case w.events <- struct{}{}: - default: + w.watchTree(path) } + queueWakeup(w.fileChanges) } // Run one cycle soon regardless of file events (-f at start, resume catch-up). func (w *repoWatcher) flushNow() { - select { - case w.flush <- struct{}{}: - default: - } + queueWakeup(w.flushRequests) } -func (w *repoWatcher) run() { - defer close(w.done) +func (w *repoWatcher) runCycles() { + defer close(w.stopped) debounce := time.NewTimer(time.Hour) debounce.Stop() retry := time.NewTimer(time.Hour) @@ -410,19 +462,19 @@ func (w *repoWatcher) run() { settle := time.Duration(w.spec.Settle * float64(time.Second)) for { select { - case <-w.events: + 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.flush: + case <-w.flushRequests: w.report(autoCommit(w.spec), retry) case <-retry.C: if w.lastError != nil { w.report(push(w.spec), retry) } - case <-w.stop: + case <-w.stopping: debounce.Stop() retry.Stop() return @@ -476,11 +528,17 @@ func (w *repoWatcher) report(outcome Outcome, retry *time.Timer) { w.lastError = &repoError{outcome: outcome, lastAttempt: time.Now(), attempts: attempts} } - w.publish() + w.publishStatus() w.logOutcome(outcome) } -func (w *repoWatcher) publish() { +// Hand this repo's error state (nil while healthy) to the state file, where +// `gitwatchd status` reads it. +func (w *repoWatcher) publishStatus() { + w.watchMu.Lock() + watchFailure := w.watchFailure + w.watchMu.Unlock() + var status *RepoStatus if w.lastError != nil { status = &RepoStatus{ @@ -496,17 +554,11 @@ func (w *repoWatcher) publish() { ts := w.lastError.nextRetry.Unix() status.NextRetry = &ts } - } else if err := w.watchError(); err != "" { - status = &RepoStatus{ErrorLabel: "watch failing", Detail: err, + } else if watchFailure != "" { + status = &RepoStatus{ErrorLabel: "watch failing", Detail: watchFailure, Attempts: 1, LastAttempt: time.Now().Unix()} } - w.onState(w.spec.Path, status) -} - -func (w *repoWatcher) watchError() string { - w.mu.Lock() - defer w.mu.Unlock() - return w.watchErr + w.onStatus(w.spec.Path, status) } func (w *repoWatcher) logOutcome(outcome Outcome) { @@ -528,72 +580,7 @@ func (w *repoWatcher) logOutcome(outcome Outcome) { } func (w *repoWatcher) stopWatching() { - close(w.stop) - syscall.Close(w.fd) - <-w.done -} - -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 pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } -func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } - -var stateMu sync.Mutex - -func stateErrors() map[string]RepoStatus { - raw, err := os.ReadFile(statePath()) - if err != nil { - return map[string]RepoStatus{} - } - var out map[string]RepoStatus - if json.Unmarshal(raw, &out) != nil || out == nil { - return map[string]RepoStatus{} - } - return out -} - -func stateSet(repoPath string, status *RepoStatus) { - stateMu.Lock() - defer stateMu.Unlock() - errs := stateErrors() - if status == nil { - delete(errs, repoPath) - } else { - errs[repoPath] = *status - } - stateWrite(errs) -} - -func statePrune(keep map[string]bool) { - stateMu.Lock() - defer stateMu.Unlock() - errs := stateErrors() - for path := range errs { - if !keep[path] { - delete(errs, path) - } - } - stateWrite(errs) -} - -func stateWrite(errs map[string]RepoStatus) { - os.MkdirAll(stateDir(), 0o755) - raw, err := json.Marshal(errs) - if err != nil { - return - } - tmp := statePath() + ".tmp" - if os.WriteFile(tmp, raw, 0o644) == nil { - os.Rename(tmp, statePath()) - } + 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 index 18a0f7d..c6bfb5c 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -342,8 +342,8 @@ func TestWatchLimitExhaustionSurfacesAsRepoState(t *testing.T) { t.Fatal(err) } t.Cleanup(w.stopWatching) - w.addWatchError("/some/dir", syscall.ENOSPC) - w.publish() + w.recordWatchFailure("/some/dir", syscall.ENOSPC) + w.publishStatus() if published == nil || published.ErrorLabel != "watch failing" { t.Fatalf("got %+v", published) } From 93bd84566cfcb8a0b69cdcf63b10976cdf10e26b Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 13:59:49 +0000 Subject: [PATCH 10/19] Escalate gitwatchd stop to SIGKILL 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 --- linux/cli.go | 38 ++++++++++++++++++++++++------- linux/daemon_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index 75de51f..7821ea4 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -463,17 +463,41 @@ func cliStop() int { // Wait for the exit so `gitwatchd stop && gitwatchd start` doesn't race // the old process. - for i := 0; i < 50 && isDaemonRunning(); i++ { - time.Sleep(100 * time.Millisecond) + if waitForDaemonExit(5 * time.Second) { + fmt.Println("✓ daemon stopped") + return 0 } - if isDaemonRunning() { - warn("daemon did not exit; force with: pkill -x gitwatchd") + + // `stop` has to end with the daemon stopped, so a process that ignores + // SIGTERM (wedged in a git call, say) gets SIGKILL rather than advice. + pid := daemonPid() + if pid <= 0 { + warn("daemon did not exit and its pidfile names no process to kill") return 1 } - fmt.Println("✓ daemon stopped") + 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 } +// Poll until the daemon has released its lock, or the timeout runs out. +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 { @@ -957,9 +981,7 @@ WantedBy=default.target if isDaemonRunning() && !unitActive() { if pid := daemonPid(); pid > 0 { syscall.Kill(pid, syscall.SIGTERM) - for i := 0; i < 50 && isDaemonRunning(); i++ { - time.Sleep(100 * time.Millisecond) - } + waitForDaemonExit(5 * time.Second) } } if code, out := systemctlUser("enable", "--now", "gitwatchd"); code != 0 { diff --git a/linux/daemon_test.go b/linux/daemon_test.go index c6bfb5c..28eeca2 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -3,6 +3,7 @@ package main import ( "os" "os/exec" + "os/signal" "path/filepath" "strconv" "strings" @@ -15,6 +16,9 @@ import ( 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") @@ -173,6 +177,56 @@ func TestAddSpawnsTheDaemon(t *testing.T) { } } +// A daemon that will not go away on SIGTERM: this same test binary +// re-executed, holding the pidfile lock `gitwatchd stop` reads the daemon's +// liveness from. +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") From 5f6c9e8cede43d63439597240183e4bd2cf389f5 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 14:34:53 +0000 Subject: [PATCH 11/19] Turn autostart on for an installed daemon's first run 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 --- linux/cli.go | 195 +++++++++++++++++++++++++++++++++++++++---- linux/cli_test.go | 74 ++++++++++++++++ linux/daemon.go | 7 ++ linux/daemon_test.go | 136 ++++++++++++++++++++++++++---- 4 files changed, 381 insertions(+), 31 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index 7821ea4..cb3bd07 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -40,7 +40,10 @@ COMMANDS status everything being watched, in the terminal start, stop start or stop the daemon autostart [on|off|status] - run the daemon at boot (installs a systemd user unit) + run the daemon at boot (installs a systemd user unit). + On by default: the first run of an installed gitwatchd + turns it on. ` + "`gitwatchd autostart off`" + ` is the + standing opt-out, and nothing turns it back on for you. config [path|edit] print the config file path, or open it in your editor (the one ` + "`git commit`" + ` uses) help show this help @@ -944,16 +947,20 @@ func unitActive() bool { return out == "active" } -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 +func unitEnabled() bool { + if !unitInstalled() { + return false } + _, out := systemctlUser("is-enabled", "gitwatchd") + return out == "enabled" +} + +// Write the systemd user unit for the running binary, so systemd knows what +// to run. Returns "" on success, or why not. +func writeAutostartUnit() string { exe, err := os.Executable() if err != nil { - warn("cannot resolve the gitwatchd binary path: " + err.Error()) - return 1 + return "cannot resolve the gitwatchd binary path: " + err.Error() } exe, _ = filepath.EvalSymlinks(exe) unit := fmt.Sprintf(`[Unit] @@ -968,14 +975,38 @@ RestartSec=5 WantedBy=default.target `, exe) if err := os.MkdirAll(filepath.Dir(unitPath()), 0o755); err != nil { - warn("cannot create " + filepath.Dir(unitPath()) + ": " + err.Error()) - return 1 + return "cannot create " + filepath.Dir(unitPath()) + ": " + err.Error() } if err := os.WriteFile(unitPath(), []byte(unit), 0o644); err != nil { - warn("cannot write " + unitPath() + ": " + err.Error()) - return 1 + return "cannot write " + unitPath() + ": " + err.Error() } systemctlUser("daemon-reload") + return "" +} + +// Lingering keeps the user manager (and the daemon) alive without an open +// session: headless boots, logged-out laptops. Returns "" or why not. +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 the outcome is known, so that a failure here is retried + // on a later daemon start instead of being forgotten. + recordAutostartWish(true) + if msg := writeAutostartUnit(); msg != "" { + warn(msg) + return 1 + } // A directly spawned daemon holds the single-instance lock and would // make the unit fail; hand it over to systemd. if isDaemonRunning() && !unitActive() { @@ -988,11 +1019,9 @@ WantedBy=default.target warn("systemctl --user enable --now gitwatchd failed: " + out) return 1 } - // Lingering keeps the user manager (and the daemon) alive without an - // open session: headless boots, logged-out laptops. - if code, out := runCommand("loginctl", []string{"enable-linger", os.Getenv("USER")}, ""); code != 0 { + if note := enableLinger(); note != "" { fmt.Println("✓ autostart: on (systemd user unit enabled)") - fmt.Println(" note: loginctl enable-linger failed (" + strings.TrimSpace(out) + ")") + fmt.Println(" note: loginctl enable-linger failed (" + note + ")") fmt.Println(" without lingering the daemon stops when you log out") return 0 } @@ -1001,6 +1030,7 @@ WantedBy=default.target } func autostartOff() int { + recordAutostartWish(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 @@ -1037,3 +1067,136 @@ func autostartStatus() int { } return 0 } + +// First-run onboarding: the daemon matches autostart to the user's recorded +// wish on every start, so an installed gitwatchd ends up running at boot +// without anyone asking for it. + +// The wish, as its own small file in the state dir: "on", "off", or absent +// when the user has never said either way. +func recordedAutostartWish() (wantsOn bool, recorded bool) { + raw, err := os.ReadFile(autostartWishPath()) + if err != nil { + return false, false + } + return strings.TrimSpace(string(raw)) != "off", true +} + +func recordAutostartWish(on bool) { + os.MkdirAll(stateDir(), 0o755) + value := "off\n" + if on { + value = "on\n" + } + os.WriteFile(autostartWishPath(), []byte(value), 0o644) +} + +// install.sh and `make uninstall` know three destinations; a binary anywhere +// else (a build directory, a checkout) is a development copy, which onboarding +// leaves alone along with the record. +func isInstalledBinary(exe string) bool { + dir, err := filepath.EvalSymlinks(filepath.Dir(exe)) + if err != nil { + return false + } + for _, root := range []string{"/usr/local/bin", + filepath.Join(homeDir(), ".local", "bin"), filepath.Join(homeDir(), "bin")} { + if resolved, err := filepath.EvalSymlinks(root); err == nil && resolved == dir { + return true + } + } + return false +} + +type autostartConditions struct { + installedBinary bool + recorded bool // the user's wish has been recorded before + wantsOn bool + systemdPresent bool + unitEnabled bool +} + +type autostartAction int + +const ( + autostartLeaveAlone autostartAction = iota + autostartEnableFirstRun + autostartReinstate + autostartReportUnavailable +) + +// What a daemon start should do about autostart. Kept apart from the doing, so +// the whole table is testable on a host without systemd. +func autostartActionFor(c autostartConditions) autostartAction { + if !c.installedBinary { + return autostartLeaveAlone + } + if c.recorded && !c.wantsOn { + return autostartLeaveAlone // `autostart off` is never overridden + } + if !c.systemdPresent { + if c.recorded { + return autostartLeaveAlone // said once already, not on every start + } + return autostartReportUnavailable + } + if !c.recorded { + return autostartEnableFirstRun + } + if c.unitEnabled { + return autostartLeaveAlone + } + return autostartReinstate +} + +// Install the unit and enable it for the next boot without starting it: the +// caller is the running daemon, and `--now` would start a second copy that +// dies on the single-instance lock. Returns "" on success, or why not. +func enableAutostartForNextBoot() string { + if msg := writeAutostartUnit(); msg != "" { + return msg + } + if code, out := systemctlUser("enable", "gitwatchd"); code != 0 { + return "systemctl --user enable gitwatchd failed: " + out + } + enableLinger() // best effort, as for `autostart on` + return "" +} + +// Make autostart match the recorded wish. The first start of an installed +// gitwatchd turns it on: a daemon that doesn't come back after a reboot is not +// doing the one job it has. Returns a line for the daemon log, or "" when +// there was nothing to do. +func reconcileAutostart() string { + exe, err := os.Executable() + if err != nil { + return "" + } + wantsOn, recorded := recordedAutostartWish() + conditions := autostartConditions{ + installedBinary: isInstalledBinary(exe), + recorded: recorded, + wantsOn: wantsOn, + systemdPresent: systemctlPresent(), + } + if conditions.systemdPresent { + conditions.unitEnabled = unitEnabled() + } + action := autostartActionFor(conditions) + if action == autostartLeaveAlone { + return "" + } + recordAutostartWish(true) // recorded before the outcome, so a failure is retried + if action == autostartReportUnavailable { + return "autostart: unavailable (systemd not found); to have the daemon come back " + + "after a reboot, run `gitwatchd start` from your session startup" + } + if msg := enableAutostartForNextBoot(); msg != "" { + return "autostart could not be enabled: " + msg + } + if action == autostartEnableFirstRun { + return "autostart: on (systemd user unit enabled on first run; " + + "turn it off with `gitwatchd autostart off`)" + } + return "autostart: the systemd user unit had gone missing, re-enabled it" +} diff --git a/linux/cli_test.go b/linux/cli_test.go index 0daea7e..1e00afd 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -301,6 +301,80 @@ func TestTokenizeRespectsQuotes(t *testing.T) { } } +// Autostart onboarding: what a daemon start does about autostart, given the +// recorded wish, where the binary lives and whether systemd is here. + +func TestAutostartDecisionTable(t *testing.T) { + installed := autostartConditions{installedBinary: true, systemdPresent: true} + cases := []struct { + what string + conditions autostartConditions + want autostartAction + }{ + {"a development copy is never onboarded", + autostartConditions{systemdPresent: true}, autostartLeaveAlone}, + {"a development copy is left alone even with a wish on record", + autostartConditions{recorded: true, wantsOn: true, systemdPresent: true}, autostartLeaveAlone}, + {"the first installed run turns autostart on", + installed, autostartEnableFirstRun}, + {"a wish that is already satisfied needs nothing", + autostartConditions{installedBinary: true, systemdPresent: true, + recorded: true, wantsOn: true, unitEnabled: true}, autostartLeaveAlone}, + {"a wanted unit that went missing is reinstated", + autostartConditions{installedBinary: true, systemdPresent: true, + recorded: true, wantsOn: true}, autostartReinstate}, + {"an opt-out is never overridden", + autostartConditions{installedBinary: true, systemdPresent: true, recorded: true}, + autostartLeaveAlone}, + {"the first installed run without systemd says so", + autostartConditions{installedBinary: true}, autostartReportUnavailable}, + {"without systemd it says so once, not on every start", + autostartConditions{installedBinary: true, recorded: true, wantsOn: true}, + autostartLeaveAlone}, + {"an opt-out without systemd stays quiet", + autostartConditions{installedBinary: true, recorded: true}, autostartLeaveAlone}, + } + for _, c := range cases { + if got := autostartActionFor(c.conditions); got != c.want { + t.Errorf("%s: action %d, want %d (%+v)", c.what, got, c.want, c.conditions) + } + } +} + +func TestAutostartWishIsRecordedInItsOwnFile(t *testing.T) { + withTemporaryConfig(t) + if _, recorded := recordedAutostartWish(); recorded { + t.Error("nothing is on record until the user or a first run says so") + } + recordAutostartWish(true) + if on, recorded := recordedAutostartWish(); !on || !recorded { + t.Errorf("after recording on: on=%v recorded=%v", on, recorded) + } + recordAutostartWish(false) + if on, recorded := recordedAutostartWish(); on || !recorded { + t.Errorf("after recording off: on=%v recorded=%v", on, recorded) + } + if _, err := os.Stat(statePath()); err == nil { + t.Error("the wish must not be written into the daemon's error state file") + } +} + +func TestOnlyAnInstalledBinaryIsOnboarded(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + for _, dir := range []string{".local/bin", "bin", "build"} { + os.MkdirAll(filepath.Join(home, dir), 0o755) + } + for _, dir := range []string{".local/bin", "bin"} { + if !isInstalledBinary(filepath.Join(home, dir, "gitwatchd")) { + t.Errorf("%s is one of the install destinations", dir) + } + } + if isInstalledBinary(filepath.Join(home, "build", "gitwatchd")) { + t.Error("a build directory holds a development copy") + } +} + // The help text states flag defaults and exclusion behaviour as facts; // these tests pin them so the help can't silently drift from the code. diff --git a/linux/daemon.go b/linux/daemon.go index e13cf4a..1f81835 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -34,6 +34,10 @@ func statePath() string { return filepath.Join(stateDir(), "state.json") } func pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } +// The autostart wish gets its own file: state.json is the daemon's error +// channel for `status`, written whole on every change. +func autostartWishPath() string { return filepath.Join(stateDir(), "autostart") } + // 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 | @@ -136,6 +140,9 @@ func runDaemon() int { }, } d.logf("gitwatchd %s: watching config %s", version, configPath()) + if msg := reconcileAutostart(); msg != "" { + d.logf("%s", msg) + } d.reloadConfig() configChanged := make(chan struct{}, 1) diff --git a/linux/daemon_test.go b/linux/daemon_test.go index 28eeca2..b08908f 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -36,7 +36,11 @@ func TestMain(m *testing.M) { } func runCLI(env []string, args ...string) (int, string) { - cmd := exec.Command(testBinary, args...) + 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 @@ -235,20 +239,7 @@ func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { t.Skip("this host runs systemd; the degrade path is exercised in plain containers") } home := t.TempDir() - env := isolatedEnv(home) - // A PATH with no systemctl. - 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)) - } - } - for i, e := range env { - if strings.HasPrefix(e, "PATH=") { - env[i] = "PATH=" + bindir - } - } + env := envWithoutSystemctl(home) code, out := runCLI(env, "autostart", "on") if code == 0 { t.Error("autostart on must fail without systemd") @@ -268,6 +259,116 @@ func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { } } +// A daemon started from a build directory is a development copy: it leaves +// the user's login setup, and the record of their wish, alone. +func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := envWithoutSystemctl(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + if code, out := runCLI(env, "start"); code != 0 { + t.Fatalf("start: code=%d out=%s", code, out) + } + // Autostart is reconciled before the first repo is watched, so this line + // in the log means it has been and gone. + waitFor(t, 15*time.Second, "the daemon to watch the repo", func() bool { + return strings.Contains(daemonLogIn(home), "watching "+filepath.Base(repo.path)+" (") + }) + if _, err := os.Stat(filepath.Join(home, "state", "autostart")); err == nil { + t.Error("a development copy must record no wish") + } + if _, err := os.Stat(filepath.Join(home, ".config")); err == nil { + t.Error("a development copy must write no unit") + } + if code, out := runCLI(env, "stop"); code != 0 { + t.Fatalf("stop: code=%d out=%s", code, out) + } +} + +// The first daemon start of an installed gitwatchd onboards autostart without +// being asked. With no systemd there is nothing to enable, so it records the +// wish, says so once, and leaves the user a way to run at boot themselves. +func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := envWithoutSystemctl(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + installed := filepath.Join(home, ".local", "bin", "gitwatchd") + os.MkdirAll(filepath.Dir(installed), 0o755) + binary, err := os.ReadFile(testBinary) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(installed, binary, 0o755); err != nil { + t.Fatal(err) + } + + repo := newTestRepo(t) + if code, out := runBinary(installed, env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + if code, out := runBinary(installed, env, "start"); code != 0 { + t.Fatalf("start: code=%d out=%s", code, out) + } + waitFor(t, 15*time.Second, "the recorded autostart wish", func() bool { + raw, _ := os.ReadFile(filepath.Join(home, "state", "autostart")) + return strings.TrimSpace(string(raw)) == "on" + }) + if log := daemonLogIn(home); !strings.Contains(log, "systemd not found") || + !strings.Contains(log, "gitwatchd start") { + t.Errorf("the log must explain and point at `gitwatchd start`:\n%s", log) + } + if _, err := os.Stat(filepath.Join(home, ".config", "systemd")); err == nil { + t.Error("no unit may be written when systemd is absent") + } + + // The wish is on record now, so a second start has nothing to say. + if code, out := runBinary(installed, env, "stop"); code != 0 { + t.Fatalf("stop: code=%d out=%s", code, out) + } + if code, out := runBinary(installed, env, "start"); code != 0 { + t.Fatalf("second start: code=%d out=%s", code, out) + } + watched := "watching " + filepath.Base(repo.path) + " (" + waitFor(t, 15*time.Second, "the restarted daemon to watch the repo", func() bool { + return strings.Count(daemonLogIn(home), watched) == 2 + }) + if n := strings.Count(daemonLogIn(home), "systemd not found"); n != 1 { + t.Errorf("autostart said it %d times; once is the whole point:\n%s", n, daemonLogIn(home)) + } + if code, out := runBinary(installed, env, "stop"); code != 0 { + t.Fatalf("second stop: code=%d out=%s", code, out) + } +} + +// An isolated environment whose PATH holds the tools gitwatchd runs and no +// systemctl, so the no-systemd paths can be exercised 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) @@ -287,6 +388,11 @@ func daemonPidIn(home string) int { return pid } +func daemonLogIn(home string) string { + raw, _ := os.ReadFile(filepath.Join(home, "state", "daemon.log")) + return string(raw) +} + func killDaemonIfRunning(home string) { if pid := daemonPidIn(home); pid > 0 { syscall.Kill(pid, syscall.SIGKILL) From 72d420650ef744ee562e31434f6be1065d2eaf06 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 14:37:56 +0000 Subject: [PATCH 12/19] Trim the oracle README Co-Authored-By: Claude Fable 5 --- oracle/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/oracle/README.md b/oracle/README.md index 0a0d239..c6c1e13 100644 --- a/oracle/README.md +++ b/oracle/README.md @@ -2,9 +2,7 @@ `gitwatch.sh` is a verbatim copy of upstream gitwatch, used as the model in the differential parity tests of both implementations -(`macos/Tests/ParityTests.swift` and `linux/gitwatch_parity_test.go`): the same -scenario runs through this script and through our engine, and the resulting git -state must be identical. +(`macos/Tests/ParityTests.swift` and `linux/gitwatch_parity_test.go`). - Source: https://raw.githubusercontent.com/gitwatch/gitwatch/master/gitwatch.sh - Fetched: 2026-07-18 From 62e74caad5311c39ceeb2808f9a9ec83ed51527a Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 14:38:04 +0000 Subject: [PATCH 13/19] Update the install next steps for default-on autostart Co-Authored-By: Claude Fable 5 --- linux/install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linux/install.sh b/linux/install.sh index 5fa018e..f9c8e75 100755 --- a/linux/install.sh +++ b/linux/install.sh @@ -30,6 +30,6 @@ esac echo "" echo "next steps:" -echo " gitwatchd watch a repo" -echo " gitwatchd autostart on run at boot (systemd user unit)" +echo " gitwatchd watch a repo (turns on run-at-boot the first time)" +echo " gitwatchd autostart off opt out of run at boot" echo " gitwatchd status see everything watched" From 28028803c8c673292c1f5d123a33e8d04e514360 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 14:44:46 +0000 Subject: [PATCH 14/19] Ignore the in-place Go binary in linux/ Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From b401df01fd5361d7e5c31e793ef5095bbca569dc Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 14:44:54 +0000 Subject: [PATCH 15/19] Keep all persistent state in one flat state.json 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 --- linux/cli.go | 33 +++------- linux/cli_test.go | 20 +++--- linux/daemon.go | 144 +++++++++++++++++++++++++++++-------------- linux/daemon_test.go | 76 ++++++++++++++++++++++- 4 files changed, 189 insertions(+), 84 deletions(-) diff --git a/linux/cli.go b/linux/cli.go index cb3bd07..89d3741 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -300,7 +300,7 @@ func cliStatus() int { } daemonErrors := map[string]RepoStatus{} if running { - daemonErrors = daemonState.statuses() + daemonErrors = repoStatuses() } repos := "repos" if len(specs) == 1 { @@ -1002,7 +1002,7 @@ func autostartOn() int { } // Recorded before the outcome is known, so that a failure here is retried // on a later daemon start instead of being forgotten. - recordAutostartWish(true) + setLaunchAtLogin(true) if msg := writeAutostartUnit(); msg != "" { warn(msg) return 1 @@ -1030,7 +1030,7 @@ func autostartOn() int { } func autostartOff() int { - recordAutostartWish(false) // a standing opt-out: no later run turns it back on + 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 @@ -1068,29 +1068,10 @@ func autostartStatus() int { return 0 } -// First-run onboarding: the daemon matches autostart to the user's recorded -// wish on every start, so an installed gitwatchd ends up running at boot +// First-run onboarding: the daemon matches autostart to the launch-at-login +// setting on every start, so an installed gitwatchd ends up running at boot // without anyone asking for it. -// The wish, as its own small file in the state dir: "on", "off", or absent -// when the user has never said either way. -func recordedAutostartWish() (wantsOn bool, recorded bool) { - raw, err := os.ReadFile(autostartWishPath()) - if err != nil { - return false, false - } - return strings.TrimSpace(string(raw)) != "off", true -} - -func recordAutostartWish(on bool) { - os.MkdirAll(stateDir(), 0o755) - value := "off\n" - if on { - value = "on\n" - } - os.WriteFile(autostartWishPath(), []byte(value), 0o644) -} - // install.sh and `make uninstall` know three destinations; a binary anywhere // else (a build directory, a checkout) is a development copy, which onboarding // leaves alone along with the record. @@ -1172,7 +1153,7 @@ func reconcileAutostart() string { if err != nil { return "" } - wantsOn, recorded := recordedAutostartWish() + wantsOn, recorded := launchAtLogin() conditions := autostartConditions{ installedBinary: isInstalledBinary(exe), recorded: recorded, @@ -1186,7 +1167,7 @@ func reconcileAutostart() string { if action == autostartLeaveAlone { return "" } - recordAutostartWish(true) // recorded before the outcome, so a failure is retried + 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" diff --git a/linux/cli_test.go b/linux/cli_test.go index 1e00afd..51ef9f0 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -341,21 +341,25 @@ func TestAutostartDecisionTable(t *testing.T) { } } -func TestAutostartWishIsRecordedInItsOwnFile(t *testing.T) { +func TestAutostartWishIsRecordedAsLaunchAtLogin(t *testing.T) { withTemporaryConfig(t) - if _, recorded := recordedAutostartWish(); recorded { + if _, recorded := launchAtLogin(); recorded { t.Error("nothing is on record until the user or a first run says so") } - recordAutostartWish(true) - if on, recorded := recordedAutostartWish(); !on || !recorded { + setLaunchAtLogin(true) + if on, recorded := launchAtLogin(); !on || !recorded { t.Errorf("after recording on: on=%v recorded=%v", on, recorded) } - recordAutostartWish(false) - if on, recorded := recordedAutostartWish(); on || !recorded { + setLaunchAtLogin(false) + if on, recorded := launchAtLogin(); on || !recorded { t.Errorf("after recording off: on=%v recorded=%v", on, recorded) } - if _, err := os.Stat(statePath()); err == nil { - t.Error("the wish must not be written into the daemon's error state file") + 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) } } diff --git a/linux/daemon.go b/linux/daemon.go index 1f81835..9f24afd 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -30,13 +30,10 @@ func stateDir() string { return filepath.Join(base, "gitwatchd") } -func statePath() string { return filepath.Join(stateDir(), "state.json") } -func pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } -func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } - -// The autostart wish gets its own file: state.json is the daemon's error -// channel for `status`, written whole on every change. -func autostartWishPath() string { return filepath.Join(stateDir(), "autostart") } +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. @@ -54,60 +51,65 @@ type RepoStatus struct { NextRetry *int64 `json:"nextRetry,omitempty"` } -// The state file: one JSON object of RepoStatus keyed by repo path. The daemon -// is the only writer, every gitwatchd process a reader. -type stateFile struct { - mu sync.Mutex // one whole-file rewrite at a time +// Everything gitwatchd persists: settings as top-level keys, and per-repo error +// state under "repos", keyed by absolute path (a healthy repo has no entry). +// +// This shape is the cross-platform contract. macOS keeps the same values in a +// SQLite key-value store today and moves onto this file later, so the setting +// names and their "on"/"off" values are the ones in macos/Sources/StateDB.swift. +// The file is written pretty-printed, in a stable key order, because it is meant +// to be read and diffed by people. +type persistedState struct { + LaunchAtLogin string `json:"launch-at-login,omitempty"` // "on", "off", absent: never asked + Repos map[string]RepoStatus `json:"repos,omitempty"` } -var daemonState stateFile - // Takes no lock: writes land by rename, so a reader always sees one whole -// version of the file. -func (s *stateFile) statuses() map[string]RepoStatus { +// version of the file. Absent, empty or unreadable state reads as empty. +func readState() persistedState { raw, err := os.ReadFile(statePath()) if err != nil { - return map[string]RepoStatus{} + return persistedState{} } - var out map[string]RepoStatus - if json.Unmarshal(raw, &out) != nil || out == nil { - return map[string]RepoStatus{} + var s persistedState + if json.Unmarshal(raw, &s) != nil { + return persistedState{} } - return out + return s } -// Record one repo's error state, or clear it when status is nil. -func (s *stateFile) setStatus(repoPath string, status *RepoStatus) { - s.mu.Lock() - defer s.mu.Unlock() - statuses := s.statuses() - if status == nil { - delete(statuses, repoPath) - } else { - statuses[repoPath] = *status +// Apply one read-modify-write to the state file under an exclusive lock. Two +// processes write it (the daemon publishes repo state, the CLI records +// settings), and a whole-file rewrite from a stale read would drop the other's +// update. +// +// The lock is a file of its own, and never state.json: writes land by renaming +// a temp file over state.json, so a lock taken on state.json itself would be +// left holding an unlinked inode while the next writer locked the file that +// replaced it, and both would proceed at once. +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 } - s.write(statuses) -} - -// Forget the repos that have left the config. -func (s *stateFile) keepOnly(watched map[string]bool) { - s.mu.Lock() - defer s.mu.Unlock() - statuses := s.statuses() - for path := range statuses { - if !watched[path] { - delete(statuses, path) - } + defer lock.Close() + if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX) != nil { + return } - s.write(statuses) + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + + s := readState() + change(&s) + writeState(s) } -func (s *stateFile) write(statuses map[string]RepoStatus) { - os.MkdirAll(stateDir(), 0o755) - raw, err := json.Marshal(statuses) +func writeState(s persistedState) { + raw, err := json.MarshalIndent(s, "", " ") if err != nil { return } + raw = append(raw, '\n') // Write beside the file and rename over it, so no reader ever sees a // half-written state. tmp := statePath() + ".tmp" @@ -116,6 +118,54 @@ func (s *stateFile) write(statuses map[string]RepoStatus) { } } +func repoStatuses() map[string]RepoStatus { + statuses := readState().Repos + if statuses == nil { + return map[string]RepoStatus{} + } + return statuses +} + +// Record one repo's error state, or clear it when status is nil. +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 + }) +} + +// Forget the repos that have left the config. +func keepOnlyRepos(watched map[string]bool) { + updateState(func(s *persistedState) { + for path := range s.Repos { + if !watched[path] { + delete(s.Repos, path) + } + } + }) +} + +// Whether the user wants the daemon started at login, and whether anyone has +// said either way yet. +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 }) +} + // The running daemon: one watcher per watchable repo in the config, kept in // step with the file as it is edited. type daemon struct { @@ -184,7 +234,7 @@ func (d *daemon) reloadConfig() { desired[s.Path] = s watched[s.Path] = true } - daemonState.keepOnly(watched) + keepOnlyRepos(watched) for path, w := range d.watchers { s, wanted := desired[path] @@ -203,7 +253,7 @@ func (d *daemon) reloadConfig() { if _, running := d.watchers[path]; running { continue } - w, err := newRepoWatcher(s, daemonState.setStatus, d.logf) + w, err := newRepoWatcher(s, setRepoStatus, d.logf) if err != nil { d.logf("could not watch %s: %v", s.Name(), err) continue diff --git a/linux/daemon_test.go b/linux/daemon_test.go index b08908f..0b91017 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -281,7 +281,7 @@ func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { waitFor(t, 15*time.Second, "the daemon to watch the repo", func() bool { return strings.Contains(daemonLogIn(home), "watching "+filepath.Base(repo.path)+" (") }) - if _, err := os.Stat(filepath.Join(home, "state", "autostart")); err == nil { + if strings.Contains(stateFileIn(home), "launch-at-login") { t.Error("a development copy must record no wish") } if _, err := os.Stat(filepath.Join(home, ".config")); err == nil { @@ -320,8 +320,7 @@ func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { t.Fatalf("start: code=%d out=%s", code, out) } waitFor(t, 15*time.Second, "the recorded autostart wish", func() bool { - raw, _ := os.ReadFile(filepath.Join(home, "state", "autostart")) - return strings.TrimSpace(string(raw)) == "on" + return strings.Contains(stateFileIn(home), `"launch-at-login": "on"`) }) if log := daemonLogIn(home); !strings.Contains(log, "systemd not found") || !strings.Contains(log, "gitwatchd start") { @@ -393,6 +392,11 @@ func daemonLogIn(home string) string { 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) @@ -511,3 +515,69 @@ func TestWatchLimitExhaustionSurfacesAsRepoState(t *testing.T) { t.Errorf("the fix should be named: %q", published.Detail) } } + +// Settings and repo state share one file, so each writer has to leave the +// other's half alone. +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) + } +} + +// The daemon and the CLI both rewrite the whole file, so each takes an +// exclusive lock for one read-modify-write. Holding that lock here keeps the +// real 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") + } + + // The daemon's read-modify-write, 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) + } +} From 626f1fdc386b99c431fc5f5e089bc4b0b586fd0d Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 14:47:45 +0000 Subject: [PATCH 16/19] Fold install.sh into the Makefile and harden uninstall 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 --- linux/Makefile | 69 +++++++++++++++++++++++++++++++++++++++--------- linux/cli.go | 2 +- linux/install.sh | 35 ------------------------ 3 files changed, 58 insertions(+), 48 deletions(-) delete mode 100755 linux/install.sh diff --git a/linux/Makefile b/linux/Makefile index 5904399..32f24c5 100644 --- a/linux/Makefile +++ b/linux/Makefile @@ -1,13 +1,15 @@ # 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 (install.sh does the work) -# make clean remove build artifacts +# make build build/gitwatchd for the host platform +# make test go vet + go test +# make install put the binary on PATH and start the daemon (no sudo) +# make uninstall remove the binary, systemd unit and daemon state +# make clean remove build artifacts GOFLAGS := -trimpath -ldflags "-s -w" BUILD := build -# Where install.sh puts the binary; override as `make install DEST=/usr/local/bin`. +# 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 @@ -21,17 +23,60 @@ 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 - @./install.sh $(DEST) + @$(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" +# Everything below finds gitwatchd by absolute path: uninstalling has to work +# from a shell whose PATH never had the binary on it. uninstall: - @gitwatchd autostart off >/dev/null 2>&1 || true - @gitwatchd stop >/dev/null 2>&1 || true @for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ - [ -f "$$d/gitwatchd" ] && rm -f "$$d/gitwatchd" && echo " removed $$d/gitwatchd"; \ - done; true - @rm -rf "$$HOME/.local/state/gitwatchd" - @echo "✓ uninstalled: binary, systemd unit and daemon state" + [ -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: diff --git a/linux/cli.go b/linux/cli.go index 89d3741..5ceaa2d 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -1072,7 +1072,7 @@ func autostartStatus() int { // setting on every start, so an installed gitwatchd ends up running at boot // without anyone asking for it. -// install.sh and `make uninstall` know three destinations; a binary anywhere +// `make install` and `make uninstall` know three destinations; a binary anywhere // else (a build directory, a checkout) is a development copy, which onboarding // leaves alone along with the record. func isInstalledBinary(exe string) bool { diff --git a/linux/install.sh b/linux/install.sh deleted file mode 100755 index f9c8e75..0000000 --- a/linux/install.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/sh -# Install the gitwatchd binary for the current user (default: ~/.local/bin). -# Usage: ./install.sh [destination-dir] -set -eu - -here=$(CDPATH= cd "$(dirname "$0")" && pwd) -bin="$here/build/gitwatchd" - -# `make install` has already built this; building here too keeps the script -# usable on its own. -if [ ! -x "$bin" ]; then - if command -v go >/dev/null 2>&1; then - echo "building gitwatchd..." - (cd "$here" && CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$bin" .) - else - echo "error: nothing built at $bin and no Go toolchain to build it" >&2 - exit 1 - fi -fi - -dest="${1:-$HOME/.local/bin}" -mkdir -p "$dest" -install -m 0755 "$bin" "$dest/gitwatchd" -echo "installed $dest/gitwatchd" - -case ":$PATH:" in - *":$dest:"*) ;; - *) echo "note: $dest is not on your PATH; add it to your shell profile" ;; -esac - -echo "" -echo "next steps:" -echo " gitwatchd watch a repo (turns on run-at-boot the first time)" -echo " gitwatchd autostart off opt out of run at boot" -echo " gitwatchd status see everything watched" From ba0ced95a62c07e3da834cfc952245f80a9e595b Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 14:57:43 +0000 Subject: [PATCH 17/19] Cut comment bloat from the autostart, state and install changes Co-Authored-By: Claude Fable 5 --- linux/Makefile | 3 +- linux/cli.go | 35 +++++++--------------- linux/daemon.go | 70 ++++++++++++-------------------------------- linux/daemon_test.go | 26 ++++------------ 4 files changed, 36 insertions(+), 98 deletions(-) diff --git a/linux/Makefile b/linux/Makefile index 32f24c5..79f1a5e 100644 --- a/linux/Makefile +++ b/linux/Makefile @@ -47,8 +47,7 @@ install: build "$$DEST/gitwatchd" start @echo " Next: gitwatchd . to watch the current repo" -# Everything below finds gitwatchd by absolute path: uninstalling has to work -# from a shell whose PATH never had the binary on it. +# 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; \ diff --git a/linux/cli.go b/linux/cli.go index 5ceaa2d..5095599 100644 --- a/linux/cli.go +++ b/linux/cli.go @@ -471,8 +471,7 @@ func cliStop() int { return 0 } - // `stop` has to end with the daemon stopped, so a process that ignores - // SIGTERM (wedged in a git call, say) gets SIGKILL rather than advice. + // 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") @@ -487,7 +486,6 @@ func cliStop() int { return 0 } -// Poll until the daemon has released its lock, or the timeout runs out. func waitForDaemonExit(timeout time.Duration) bool { deadline := time.Now().Add(timeout) for { @@ -955,8 +953,7 @@ func unitEnabled() bool { return out == "enabled" } -// Write the systemd user unit for the running binary, so systemd knows what -// to run. Returns "" on success, or why not. +// Returns "" on success, or why not. func writeAutostartUnit() string { exe, err := os.Executable() if err != nil { @@ -984,8 +981,7 @@ WantedBy=default.target return "" } -// Lingering keeps the user manager (and the daemon) alive without an open -// session: headless boots, logged-out laptops. Returns "" or why not. +// 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 { @@ -1000,8 +996,7 @@ func autostartOn() int { " run the daemon manually instead: gitwatchd start") return 1 } - // Recorded before the outcome is known, so that a failure here is retried - // on a later daemon start instead of being forgotten. + // Recorded before attempting, so a failed enable is retried on a later start. setLaunchAtLogin(true) if msg := writeAutostartUnit(); msg != "" { warn(msg) @@ -1068,13 +1063,9 @@ func autostartStatus() int { return 0 } -// First-run onboarding: the daemon matches autostart to the launch-at-login -// setting on every start, so an installed gitwatchd ends up running at boot -// without anyone asking for it. +// First-run onboarding: an installed gitwatchd ends up running at boot without anyone asking. -// `make install` and `make uninstall` know three destinations; a binary anywhere -// else (a build directory, a checkout) is a development copy, which onboarding -// leaves alone along with the record. +// A binary outside the three install destinations is a development copy: onboarding leaves it alone. func isInstalledBinary(exe string) bool { dir, err := filepath.EvalSymlinks(filepath.Dir(exe)) if err != nil { @@ -1091,7 +1082,7 @@ func isInstalledBinary(exe string) bool { type autostartConditions struct { installedBinary bool - recorded bool // the user's wish has been recorded before + recorded bool wantsOn bool systemdPresent bool unitEnabled bool @@ -1106,8 +1097,6 @@ const ( autostartReportUnavailable ) -// What a daemon start should do about autostart. Kept apart from the doing, so -// the whole table is testable on a host without systemd. func autostartActionFor(c autostartConditions) autostartAction { if !c.installedBinary { return autostartLeaveAlone @@ -1130,9 +1119,7 @@ func autostartActionFor(c autostartConditions) autostartAction { return autostartReinstate } -// Install the unit and enable it for the next boot without starting it: the -// caller is the running daemon, and `--now` would start a second copy that -// dies on the single-instance lock. Returns "" on success, or why not. +// No --now: the caller is the running daemon, and a second copy dies on the single-instance lock. func enableAutostartForNextBoot() string { if msg := writeAutostartUnit(); msg != "" { return msg @@ -1144,10 +1131,8 @@ func enableAutostartForNextBoot() string { return "" } -// Make autostart match the recorded wish. The first start of an installed -// gitwatchd turns it on: a daemon that doesn't come back after a reboot is not -// doing the one job it has. Returns a line for the daemon log, or "" when -// there was nothing to do. +// First start of an installed gitwatchd turns autostart on: a daemon that does +// not come back after a reboot is not doing its one job. func reconcileAutostart() string { exe, err := os.Executable() if err != nil { diff --git a/linux/daemon.go b/linux/daemon.go index 9f24afd..747df2e 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -17,8 +17,7 @@ import ( "unsafe" ) -// Where the daemon keeps its runtime files. GITWATCHD_STATE_DIR overrides the -// lot, which is how the tests get a daemon of their own. +// 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 @@ -51,21 +50,15 @@ type RepoStatus struct { NextRetry *int64 `json:"nextRetry,omitempty"` } -// Everything gitwatchd persists: settings as top-level keys, and per-repo error -// state under "repos", keyed by absolute path (a healthy repo has no entry). -// -// This shape is the cross-platform contract. macOS keeps the same values in a -// SQLite key-value store today and moves onto this file later, so the setting -// names and their "on"/"off" values are the ones in macos/Sources/StateDB.swift. -// The file is written pretty-printed, in a stable key order, because it is meant -// to be read and diffed by people. +// 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"` } -// Takes no lock: writes land by rename, so a reader always sees one whole -// version of the file. Absent, empty or unreadable state reads as empty. +// Lock-free: writes land by rename. Absent or unreadable state reads as empty. func readState() persistedState { raw, err := os.ReadFile(statePath()) if err != nil { @@ -78,15 +71,10 @@ func readState() persistedState { return s } -// Apply one read-modify-write to the state file under an exclusive lock. Two -// processes write it (the daemon publishes repo state, the CLI records -// settings), and a whole-file rewrite from a stale read would drop the other's -// update. -// -// The lock is a file of its own, and never state.json: writes land by renaming -// a temp file over state.json, so a lock taken on state.json itself would be -// left holding an unlinked inode while the next writer locked the file that -// replaced it, and both would proceed at once. +// 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) @@ -110,8 +98,6 @@ func writeState(s persistedState) { return } raw = append(raw, '\n') - // Write beside the file and rename over it, so no reader ever sees a - // half-written state. tmp := statePath() + ".tmp" if os.WriteFile(tmp, raw, 0o644) == nil { os.Rename(tmp, statePath()) @@ -126,7 +112,6 @@ func repoStatuses() map[string]RepoStatus { return statuses } -// Record one repo's error state, or clear it when status is nil. func setRepoStatus(repoPath string, status *RepoStatus) { updateState(func(s *persistedState) { if status == nil { @@ -140,7 +125,6 @@ func setRepoStatus(repoPath string, status *RepoStatus) { }) } -// Forget the repos that have left the config. func keepOnlyRepos(watched map[string]bool) { updateState(func(s *persistedState) { for path := range s.Repos { @@ -151,8 +135,6 @@ func keepOnlyRepos(watched map[string]bool) { }) } -// Whether the user wants the daemon started at login, and whether anyone has -// said either way yet. func launchAtLogin() (on bool, recorded bool) { value := readState().LaunchAtLogin return value != "off", value != "" @@ -166,8 +148,7 @@ func setLaunchAtLogin(on bool) { updateState(func(s *persistedState) { s.LaunchAtLogin = value }) } -// The running daemon: one watcher per watchable repo in the config, kept in -// step with the file as it is edited. +// 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 @@ -219,9 +200,6 @@ func runDaemon() int { return 0 } -// Bring the watchers in line with the config: stop the ones whose repo left, -// was paused or had its flags edited, and start one for every watchable repo -// that has none. func (d *daemon) reloadConfig() { specs, errs := configLoad() for _, e := range errs { @@ -272,8 +250,7 @@ func (d *daemon) reloadConfig() { d.previousSpecs = desired } -// Watch the config file's directory and report every change to the file -// itself (editors typically replace the file, so watching the path breaks). +// 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 { @@ -291,9 +268,8 @@ func watchConfigFile(changed chan struct{}) { }) } -// Read from an inotify fd until it closes, calling onEvent for every event. -// Each event is a fixed-size header followed by Len bytes of NUL-padded name, -// and the read buffer has to be big enough to hold a whole one. +// 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 { @@ -314,9 +290,7 @@ func readInotifyEvents(fd int, onEvent func(watchDescriptor int, mask uint32, na } } -// Ask the receiver to run once more. A wakeup already queued covers whatever -// just happened (the receiver reads current state), so a full channel is a -// success, not a reason to block. +// A wakeup already queued covers this one: the receiver reads current state. func queueWakeup(ch chan struct{}) { select { case ch <- struct{}{}: @@ -369,13 +343,10 @@ type repoError struct { nextRetry *time.Time // nil: no auto retry, waits for a change } -// Recursive inotify watcher for one repo, with a debounce (gitwatch's -s) and -// .git-churn filtering so our own commits don't retrigger the watcher. Also -// honors gitwatch's -x exclude patterns. -// -// On top of the gitwatch cycle it keeps an additive reliability layer: per-repo -// error state and automatic push retries with backoff. The layer never changes -// what a commit cycle does; it only re-runs the push stage of one that failed. +// 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 @@ -391,8 +362,7 @@ type repoWatcher struct { lastError *repoError watchFailure string // e.g. the inotify watch limit; surfaced, never fatal - // Called after every cycle with the repo path and its error state - // (nil while healthy); the daemon publishes it for `status`. + // 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) } @@ -589,8 +559,6 @@ func (w *repoWatcher) report(outcome Outcome, retry *time.Timer) { w.logOutcome(outcome) } -// Hand this repo's error state (nil while healthy) to the state file, where -// `gitwatchd status` reads it. func (w *repoWatcher) publishStatus() { w.watchMu.Lock() watchFailure := w.watchFailure diff --git a/linux/daemon_test.go b/linux/daemon_test.go index 0b91017..c138134 100644 --- a/linux/daemon_test.go +++ b/linux/daemon_test.go @@ -181,9 +181,7 @@ func TestAddSpawnsTheDaemon(t *testing.T) { } } -// A daemon that will not go away on SIGTERM: this same test binary -// re-executed, holding the pidfile lock `gitwatchd stop` reads the daemon's -// liveness from. +// This test binary re-executed: holds the pidfile lock and ignores SIGTERM. const stubbornDaemonEnv = "GITWATCHD_TEST_STUBBORN_DAEMON" func holdTheDaemonLockIgnoringSIGTERM() { @@ -259,8 +257,6 @@ func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { } } -// A daemon started from a build directory is a development copy: it leaves -// the user's login setup, and the record of their wish, alone. func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { if testBinary == "" { t.Fatal("test binary did not build") @@ -276,8 +272,7 @@ func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { if code, out := runCLI(env, "start"); code != 0 { t.Fatalf("start: code=%d out=%s", code, out) } - // Autostart is reconciled before the first repo is watched, so this line - // in the log means it has been and gone. + // Reconcile runs before the first repo is watched, so this log line means it is done. waitFor(t, 15*time.Second, "the daemon to watch the repo", func() bool { return strings.Contains(daemonLogIn(home), "watching "+filepath.Base(repo.path)+" (") }) @@ -292,9 +287,6 @@ func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { } } -// The first daemon start of an installed gitwatchd onboards autostart without -// being asked. With no systemd there is nothing to enable, so it records the -// wish, says so once, and leaves the user a way to run at boot themselves. func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { if testBinary == "" { t.Fatal("test binary did not build") @@ -349,8 +341,7 @@ func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { } } -// An isolated environment whose PATH holds the tools gitwatchd runs and no -// systemctl, so the no-systemd paths can be exercised on any host. +// 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) @@ -516,8 +507,6 @@ func TestWatchLimitExhaustionSurfacesAsRepoState(t *testing.T) { } } -// Settings and repo state share one file, so each writer has to leave the -// other's half alone. func TestStateFileKeepsSettingsAndRepoStateApart(t *testing.T) { t.Setenv("GITWATCHD_STATE_DIR", filepath.Join(t.TempDir(), "state")) setRepoStatus("/repo/one", &RepoStatus{ErrorLabel: "push failing", Attempts: 3}) @@ -533,10 +522,8 @@ func TestStateFileKeepsSettingsAndRepoStateApart(t *testing.T) { } } -// The daemon and the CLI both rewrite the whole file, so each takes an -// exclusive lock for one read-modify-write. Holding that lock here keeps the -// real binary's `autostart off` waiting until a repo status has landed, which -// its own update then has to preserve. +// 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") @@ -565,8 +552,7 @@ func TestStateFileWritesAreArbitratedBetweenProcesses(t *testing.T) { t.Fatal("the other writer got in while the lock was held") } - // The daemon's read-modify-write, by hand: this test already holds the lock - // updateState would wait for. + // 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) From edc64c3c504ae9a621fa14eaf3c80a62cb84e503 Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 15:02:26 +0000 Subject: [PATCH 18/19] Keep the push backoff at its cap instead of overflowing 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 --- linux/cli_test.go | 6 ++++++ linux/daemon.go | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/linux/cli_test.go b/linux/cli_test.go index 51ef9f0..fa2366e 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -639,6 +639,12 @@ func TestBackoffSchedule(t *testing.T) { 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) { diff --git a/linux/daemon.go b/linux/daemon.go index 747df2e..9c8454d 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -519,11 +519,13 @@ func backoffDelay(afterFailures int) time.Duration { if afterFailures <= 1 { return backoffFirst } - d := time.Duration(float64(backoffFirst) * math.Pow(2, float64(afterFailures-1))) - if d > backoffCap { + // 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 d + return time.Duration(d) } // Fold an outcome into the error state, schedule the next automatic retry, From d38875f234528311ce05e6722021ceb4d56d0b9a Mon Sep 17 00:00:00 2001 From: Will Howard Date: Wed, 29 Jul 2026 15:08:19 +0000 Subject: [PATCH 19/19] Raise the push retry cap to 30 minutes Co-Authored-By: Claude Fable 5 --- linux/cli_test.go | 3 ++- linux/daemon.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/linux/cli_test.go b/linux/cli_test.go index fa2366e..cc5dc22 100644 --- a/linux/cli_test.go +++ b/linux/cli_test.go @@ -633,7 +633,8 @@ func TestTruncation(t *testing.T) { func TestBackoffSchedule(t *testing.T) { want := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second, - 240 * time.Second, 300 * time.Second, 300 * 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) diff --git a/linux/daemon.go b/linux/daemon.go index 9c8454d..5ff8891 100644 --- a/linux/daemon.go +++ b/linux/daemon.go @@ -509,10 +509,10 @@ func (w *repoWatcher) runCycles() { } } -// Push retry backoff: 30s doubling to a 5 minute cap. +// Push retry backoff: 30s doubling to a 30 minute cap. const ( backoffFirst = 30 * time.Second - backoffCap = 300 * time.Second + backoffCap = 30 * time.Minute ) func backoffDelay(afterFailures int) time.Duration {