diff --git a/.gitignore b/.gitignore index 41e1cd9..3c464fc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ NOTES.md # Code-signing material (private key + CSR); never part of this repo signing/ + +# Go binary built in-place by `go build .` in linux/ (make builds into build/) +/linux/gitwatchd diff --git a/linux/Makefile b/linux/Makefile index 81d26dd..79f1a5e 100644 --- a/linux/Makefile +++ b/linux/Makefile @@ -1,20 +1,82 @@ # gitwatchd for Linux: a single static Go binary. -# make build build/gitwatchd for the host platform -# make test go vet + go test -# make clean remove build artifacts +# make build build/gitwatchd for the host platform +# make test go vet + go test +# make install put the binary on PATH and start the daemon (no sudo) +# make uninstall remove the binary, systemd unit and daemon state +# make clean remove build artifacts GOFLAGS := -trimpath -ldflags "-s -w" +BUILD := build -.PHONY: all build test clean +# Install destination; empty means the first writable of /usr/local/bin, +# ~/.local/bin, ~/bin. Override as `make install DEST=/custom/bin`. +DEST ?= + +.PHONY: all build test install uninstall clean all: build build: - CGO_ENABLED=0 go build $(GOFLAGS) -o build/gitwatchd . + CGO_ENABLED=0 go build $(GOFLAGS) -o $(BUILD)/gitwatchd . test: go vet ./... go test ./... +# --- local install / uninstall --- +# Sudo-free: binary onto PATH, then started from there, which is what turns +# autostart on for the first run of an installed copy. + +install: build + @$(BUILD)/gitwatchd stop >/dev/null 2>&1 || true + @DEST="$(DEST)"; \ + if [ -n "$$DEST" ]; then mkdir -p "$$DEST"; else \ + for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + mkdir -p "$$d" 2>/dev/null || true; \ + if [ -w "$$d" ]; then DEST="$$d"; break; fi; \ + done; \ + fi; \ + if [ -z "$$DEST" ]; then \ + echo " ⚠ no writable bin dir found. Install by hand: install -m 0755 $(BUILD)/gitwatchd /usr/local/bin/gitwatchd"; \ + exit 1; \ + fi; \ + install -m 0755 $(BUILD)/gitwatchd "$$DEST/gitwatchd"; \ + echo "✓ binary → $$DEST/gitwatchd"; \ + case ":$$PATH:" in *":$$DEST:"*) ;; \ + *) echo " ⚠ $$DEST is not on your PATH. Add: export PATH=\"$$DEST:\$$PATH\"";; esac; \ + "$$DEST/gitwatchd" start + @echo " Next: gitwatchd . to watch the current repo" + +# Uninstall never trusts PATH: everything is found by absolute path. +uninstall: + @for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + [ -x "$$d/gitwatchd" ] && "$$d/gitwatchd" autostart off >/dev/null 2>&1 || true; \ + done + @UNIT="$$HOME/.config/systemd/user/gitwatchd.service"; \ + STATE="$${XDG_STATE_HOME:-$$HOME/.local/state}/gitwatchd"; GONE=""; \ + if command -v systemctl >/dev/null 2>&1; then \ + systemctl --user disable --now gitwatchd >/dev/null 2>&1 || true; \ + fi; \ + if [ -f "$$UNIT" ]; then \ + rm -f "$$UNIT"; GONE="systemd unit"; echo " removed $$UNIT"; \ + command -v systemctl >/dev/null 2>&1 && systemctl --user daemon-reload >/dev/null 2>&1 || true; \ + fi; \ + PID=$$(cat "$$STATE/gitwatchd.pid" 2>/dev/null); \ + if [ -n "$$PID" ] && [ "$$(cat /proc/$$PID/comm 2>/dev/null)" = gitwatchd ]; then \ + kill "$$PID" 2>/dev/null || true; \ + for i in 1 2 3 4 5 6 7 8 9 10; do [ -d /proc/$$PID ] || break; sleep 0.2; done; \ + [ -d /proc/$$PID ] && kill -9 "$$PID" 2>/dev/null; \ + echo " stopped daemon (pid $$PID)"; \ + fi; \ + BIN=""; \ + for d in /usr/local/bin "$$HOME/.local/bin" "$$HOME/bin"; do \ + [ -f "$$d/gitwatchd" ] && rm -f "$$d/gitwatchd" && BIN=1 && echo " removed $$d/gitwatchd"; \ + done; \ + [ -n "$$BIN" ] && GONE="$${GONE:+$$GONE, }binary"; \ + [ -d "$$STATE" ] && rm -rf "$$STATE" && GONE="$${GONE:+$$GONE, }daemon state"; \ + if [ -n "$$GONE" ]; then echo "✓ uninstalled: $$GONE"; \ + else echo "✓ nothing to uninstall: no gitwatchd binary, unit or state found"; fi + @echo " (config at ~/.gitwatchd kept: 'rm ~/.gitwatchd' to reset watched repos too)" + clean: - rm -rf build + rm -rf $(BUILD) diff --git a/linux/cli.go b/linux/cli.go new file mode 100644 index 0000000..5095599 --- /dev/null +++ b/linux/cli.go @@ -0,0 +1,1168 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" + "unicode" +) + +// Keep in step with the macOS CLI (macos/Sources/CLI.swift) and Info.plist: +// the two implementations ship as one product and report one version. +const version = "0.2.0" + +const usageText = `gitwatchd - daemon that watches git repos and auto-commits changes + +USAGE + gitwatchd [flags] watch a repo + gitwatchd [args] + +EXAMPLES + gitwatchd . watch the current repo, commit locally + gitwatchd -r origin . also push every commit to origin + gitwatchd -r origin -b main -R . two-way sync: fetch commits made on + other machines and rebase yours on top + before each push to origin/main + gitwatchd status see everything being watched + gitwatchd rm blog stop watching, by name or path + +COMMANDS + add [flags] watch a repo (bare ` + "`gitwatchd [flags] `" + ` works too) + rm stop watching a repo + pause stop watching temporarily; the repo stays listed + resume start watching again and commit what piled up + status everything being watched, in the terminal + start, stop start or stop the daemon + autostart [on|off|status] + run the daemon at boot (installs a systemd user unit). + On by default: the first run of an installed gitwatchd + turns it on. ` + "`gitwatchd autostart off`" + ` is the + standing opt-out, and nothing turns it back on for you. + config [path|edit] print the config file path, or open it in your + editor (the one ` + "`git commit`" + ` uses) + help show this help + version print the version + +FLAGS (for add) + -s Wait after the last change before committing, so a + batch of writes lands as one commit. Default: 2. + -r Push to after every commit. Default: no push. + -b Branch to push to. Without it, a plain ` + "`git push `" + ` + decides. Only meaningful together with -r. + -R Before each push, pull commits made elsewhere and rebase + yours on top (` + "`git pull --rebase `" + `). Use with -r + when more than one machine pushes to the same branch. + -m Commit message; %d becomes the timestamp. + Default: "gitwatchd auto-commit (%d)". + -d Format string for that timestamp (see ` + "`man date`" + `). + Default: "+%Y-%m-%d %H:%M:%S". + -x Skip changes whose path matches this regular + expression (e.g. '\.log$' or 'build/'). + -M Skip committing while the repo has a merge in progress. + -f Commit anything already pending as soon as watching + starts (daemon launch, or when the repo is added). + -g Location of the .git directory, if elsewhere (--git-dir). + --paused Keep the repo in the config but don't watch it. This is + what ` + "`gitwatchd pause`" + ` sets. + +The daemon runs in the background and watches every repo listed in ~/.gitwatchd +` + +type ConfigError struct { + Label string // repo name, or the offending line for parse errors + Reason string // short fixed vocabulary, e.g. "repo not found" + Detail string // full path or config line, for status + RepoPath string // set when there is a path to re-check for healing +} + +// A watched-repo specification, expressed in gitwatch's flag vocabulary. +// +// gitwatch [-s secs] [-d fmt] [-r remote [-b branch]] [-R] [-m msg] +// [-x pattern] [-M] [-g gitdir] [-e events] +// +// Each config line is exactly the argument string you'd pass to gitwatch (modulo path resolution). +type RepoSpec struct { + Path string + Settle float64 // -s debounce seconds + DateFormat string // -d + Remote string // -r + Branch string // -b + Rebase bool // -R pull --rebase before push + Message string // -m (%d -> date) + Exclude string // -x regex; last one wins, as upstream + NoMergeCommit bool // -M + CommitOnStart bool // -f commit pending changes when watching starts + GitDir string // -g --git-dir + Paused bool // --paused (gitwatchd extension, not gitwatch) + Raw string // the config line this spec came from, for change detection + targetIndex int // which arg was the target, so add can persist it resolved +} + +func (s RepoSpec) Name() string { + return filepath.Base(s.Path) +} + +func (s RepoSpec) IsFileTarget() bool { + info, err := os.Stat(s.Path) + return err != nil || !info.IsDir() +} + +// Where git commands run: the target itself, or its parent for a file +// target (upstream's TARGETDIR). +func (s RepoSpec) WorkDir() string { + if s.IsFileTarget() { + return filepath.Dir(s.Path) + } + return s.Path +} + +func (s RepoSpec) Excludes(fullPath string) bool { + if s.Exclude == "" { + return false + } + re, err := regexp.Compile(s.Exclude) + if err != nil { + return false + } + return re.MatchString(fullPath) +} + +func main() { + os.Exit(cliRun(os.Args[1:])) +} + +func cliRun(args []string) int { + if len(args) == 0 { + os.Stdout.WriteString(usageText) + return 0 + } + switch args[0] { + case "help", "-h", "--help": + os.Stdout.WriteString(usageText) + return 0 + case "version", "--version": + fmt.Println("gitwatchd " + version) + return 0 + case "rm", "remove": + return cliRemove(args[1:]) + case "pause": + return cliSetPaused(args[1:], true) + case "resume": + return cliSetPaused(args[1:], false) + case "status": + return cliStatus() + case "doctor": + return cliDoctor() + case "start": + return cliStart() + case "stop": + return cliStop() + case "config": + return cliConfig(args[1:]) + case "autostart": + return cliAutostart(args[1:]) + case "add": + return cliAdd(args[1:]) + case "daemon": + return runDaemon() + default: + return cliAdd(args) + } +} + +func cliAdd(args []string) int { + spec, errMsg := parseRepoSpec(args) + if spec == nil { + if errMsg == "" { + errMsg = "could not parse arguments" + } + warn(errMsg) + return 1 + } + if _, err := os.Stat(spec.Path); err != nil { + warn("path does not exist: " + spec.Path) + return 1 + } + if !isRepo(spec.WorkDir(), spec.GitDir) { + warn("not inside a git repo: " + spec.Path + "\n run: git init " + spec.WorkDir()) + return 1 + } + for _, existing := range configSpecs() { + if existing.Path == spec.Path { + warn(fmt.Sprintf("already watching %s (%s)", spec.Name(), spec.Path)) + return 1 + } + } + + // Persist what the user typed (gitwatch-style line), with the target + // resolved to an absolute path: the daemon reads this file from a + // different working directory, so `gitwatchd .` must not store ".". + quoted := make([]string, len(args)) + for i, a := range args { + if i == spec.targetIndex { + a = spec.Path + } + quoted[i] = quoteIfNeeded(a) + } + configAppend(strings.Join(quoted, " ")) + ensureDaemonRunning() + + pushNote := "local only" + if spec.Remote != "" { + branch := spec.Branch + if branch == "" { + branch = currentBranch(spec.WorkDir(), spec.GitDir) + } + pushNote = "→ " + spec.Remote + "/" + branch + } + fmt.Printf("✓ watching %s (%s) %s, settle %ds\n", spec.Name(), spec.Path, pushNote, int(spec.Settle)) + fmt.Println(" gitwatchd status to see everything watched") + return 0 +} + +func cliRemove(args []string) int { + if len(args) == 0 { + warn("usage: gitwatchd rm ") + return 1 + } + nameOrPath := args[0] + n := configRemove(nameOrPath) + if n == 0 { + warn("no watched repo matches " + nameOrPath) + return 1 + } + ensureDaemonRunning() + entries := "entries" + if n == 1 { + entries = "entry" + } + fmt.Printf("✓ stopped watching %s (%d %s removed)\n", nameOrPath, n, entries) + return 0 +} + +func cliSetPaused(args []string, paused bool) int { + verb := "resume" + if paused { + verb = "pause" + } + if len(args) == 0 { + warn("usage: gitwatchd " + verb + " ") + return 1 + } + nameOrPath := args[0] + n := configSetPaused(nameOrPath, paused) + if n == 0 { + known := false + for _, s := range configSpecs() { + if configMatches(s, nameOrPath) { + known = true + } + } + if known { + state := "watching" + if paused { + state = "paused" + } + warn(nameOrPath + " is already " + state) + } else { + warn("no watched repo matches " + nameOrPath) + } + return 1 + } + ensureDaemonRunning() + if paused { + fmt.Printf("⏸ paused %s (resume with: gitwatchd resume %s)\n", nameOrPath, nameOrPath) + } else { + fmt.Printf("✓ resumed %s; catching up on anything that changed meanwhile\n", nameOrPath) + } + return 0 +} + +func cliStatus() int { + running := isDaemonRunning() + state := "not running" + if running { + state = "running" + } + fmt.Println("daemon: " + state) + fmt.Println("config: " + configPath()) + fmt.Println("") + specs, errors := configLoad() + if len(specs) == 0 && len(errors) == 0 { + fmt.Println("No repos watched yet") + return 0 + } + daemonErrors := map[string]RepoStatus{} + if running { + daemonErrors = repoStatuses() + } + repos := "repos" + if len(specs) == 1 { + repos = "repo" + } + fmt.Printf("Watching %d %s\n", len(specs), repos) + fmt.Println("") + now := time.Now() + for _, s := range specs { + branch := currentBranch(s.WorkDir(), s.GitDir) + pending := pendingCount(s.WorkDir(), s.GitDir) + errStatus, hasErr := daemonErrors[s.Path] + label := "" + if hasErr { + label = errStatus.ErrorLabel + } + fmt.Println(rowTitle(s.Name(), branch, s.Paused, pending, label)) + fmt.Println(" " + lastCommitSummary(s.WorkDir(), s.GitDir)) + if hasErr { + fmt.Println(" " + errorHeadline(errStatus.ErrorLabel, errStatus.Attempts)) + if errStatus.Detail != "" { + fmt.Println(" " + truncated(errStatus.Detail, 60)) + } + var next *time.Time + if errStatus.NextRetry != nil { + t := time.Unix(*errStatus.NextRetry, 0) + next = &t + } + fmt.Println(" " + retryLine(time.Unix(errStatus.LastAttempt, 0), next, now)) + } + } + for _, e := range errors { + fmt.Println(configErrorRow(e.Label, e.Reason)) + fmt.Println(" " + e.Detail) + } + return 0 +} + +// Undocumented diagnostic: the environment the daemon will use for git. +func cliDoctor() int { + fmt.Println("What the daemon will use for git:") + gitPath, err := exec.LookPath("git") + if err != nil { + fmt.Println(" git binary not found in PATH") + return 1 + } + _, gitVersion := runCommand(gitPath, []string{"--version"}, "") + fmt.Println(" git binary " + gitPath) + fmt.Println(" git --version " + gitVersion) + path := os.Getenv("PATH") + if path == "" { + path = "(unset)" + } + fmt.Println(" PATH " + path) + if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { + code, out := runCommand("ssh-add", []string{"-l"}, "") + n := 0 + if code == 0 && out != "" { + n = len(strings.Split(out, "\n")) + } + keys := "keys" + if n == 1 { + keys = "key" + } + fmt.Printf(" SSH_AUTH_SOCK present · %d %s in agent\n", n, keys) + if n == 0 { + fmt.Println(" ⚠ no keys loaded: SSH pushes may fail. Add: ssh-add ~/.ssh/id_ed25519") + } + } else { + fmt.Println(" SSH_AUTH_SOCK (unset) ⚠ SSH pushes will fail from the daemon unless keys are unencrypted") + } + return 0 +} + +func cliConfig(args []string) int { + if len(args) == 0 || args[0] == "path" { + fmt.Println(configPath()) + return 0 + } + if args[0] == "edit" { + return cliEditConfig() + } + warn("usage: gitwatchd config [path|edit]") + return 1 +} + +// Open the config in the editor `git commit` would use: `git var GIT_EDITOR` +// resolves $GIT_EDITOR, core.editor, $VISUAL, $EDITOR, then vi. Execs the +// editor in place of the CLI so full-screen editors keep the terminal; the +// daemon live-reloads off the file change, so there is nothing to do after. +func cliEditConfig() int { + configEnsureExists() + cwd, _ := os.Getwd() + code, out := gitRun([]string{"var", "GIT_EDITOR"}, cwd, "") + editor := "vi" + if code == 0 && out != "" { + editor = out + } + // The editor value can be a command with flags (e.g. "code --wait"), + // so hand it to the shell; the path rides in as $1, safely quoted. + argv := []string{"sh", "-c", editor + " \"$1\"", "gitwatchd-edit", configPath()} + err := syscall.Exec("/bin/sh", argv, os.Environ()) + warn(fmt.Sprintf("couldn't launch editor '%s': %v", editor, err)) + return 1 +} + +func cliAutostart(args []string) int { + if len(args) == 0 { + return autostartStatus() + } + switch args[0] { + case "on": + return autostartOn() + case "off": + return autostartOff() + case "status": + return autostartStatus() + default: + warn("usage: gitwatchd autostart [on|off|status]") + return 1 + } +} + +func cliStart() int { + if isDaemonRunning() { + fmt.Println("daemon already running") + return 0 + } + if unitInstalled() && systemctlPresent() { + if code, out := systemctlUser("start", "gitwatchd"); code != 0 { + warn("systemctl --user start gitwatchd failed: " + out) + return 1 + } + } else if err := spawnDaemon(); err != nil { + warn("could not start the daemon: " + err.Error()) + return 1 + } + for i := 0; i < 20 && !isDaemonRunning(); i++ { + time.Sleep(100 * time.Millisecond) + } + if !isDaemonRunning() { + logs := logfilePath() + if unitInstalled() && systemctlPresent() { + logs = "journalctl --user -u gitwatchd" + } + warn("daemon did not come up; check " + logs) + return 1 + } + fmt.Println("✓ daemon started") + return 0 +} + +func cliStop() int { + if !isDaemonRunning() { + fmt.Println("daemon not running") + return 0 + } + if unitInstalled() && systemctlPresent() && unitActive() { + systemctlUser("stop", "gitwatchd") + } else if pid := daemonPid(); pid > 0 { + syscall.Kill(pid, syscall.SIGTERM) + } + + // Wait for the exit so `gitwatchd stop && gitwatchd start` doesn't race + // the old process. + if waitForDaemonExit(5 * time.Second) { + fmt.Println("✓ daemon stopped") + return 0 + } + + // A daemon wedged in a git call ignores SIGTERM; stop still has to win. + pid := daemonPid() + if pid <= 0 { + warn("daemon did not exit and its pidfile names no process to kill") + return 1 + } + syscall.Kill(pid, syscall.SIGKILL) + if !waitForDaemonExit(2 * time.Second) { + warn(fmt.Sprintf("daemon (pid %d) survived SIGKILL", pid)) + return 1 + } + fmt.Println("✓ daemon stopped (it ignored the stop signal, so it was killed)") + return 0 +} + +func waitForDaemonExit(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if !isDaemonRunning() { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(100 * time.Millisecond) + } +} + +func spawnDaemon() error { + exe, err := os.Executable() + if err != nil { + return err + } + os.MkdirAll(stateDir(), 0o755) + logFile, err := os.OpenFile(logfilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer logFile.Close() + cmd := exec.Command(exe, "daemon") + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +// Best-effort: start the daemon if it isn't running (the running daemon +// live-reloads the config, so this is only for the cold case). +func ensureDaemonRunning() { + if os.Getenv("GITWATCHD_NO_SPAWN") != "" || isDaemonRunning() { + return + } + if unitInstalled() && systemctlPresent() { + systemctlUser("start", "gitwatchd") + return + } + spawnDaemon() +} + +func warn(msg string) { + fmt.Fprintln(os.Stderr, "✗ "+msg) +} + +// Config = a list of gitwatch-style argument lines, one repo per line. +// Hand-editable and CLI-writable; the CLI appends exactly what the user typed. + +func configPath() string { + if p := os.Getenv("GITWATCHD_CONFIG"); p != "" { + return p + } + return filepath.Join(homeDir(), ".gitwatchd") +} + +func configEnsureExists() { + if _, err := os.Stat(configPath()); err == nil { + return + } + template := `# gitwatchd: one repo per line. +# [-s secs] [-r remote [-b branch]] [-R] [-m msg] [-x pattern] [-M] [--paused] +# 'gitwatchd help' explains each flag. Examples: +# ~/code/my-notes +# -s 5 -r origin -b main ~/code/blog +# (from a terminal, 'gitwatchd .' adds the current repo here for you) +` + os.WriteFile(configPath(), []byte(template), 0o644) +} + +func configRawLines() []string { + text, err := os.ReadFile(configPath()) + if err != nil { + return nil + } + var lines []string + for _, l := range strings.Split(string(text), "\n") { + l = strings.TrimSpace(l) + if l != "" && !strings.HasPrefix(l, "#") { + lines = append(lines, l) + } + } + return lines +} + +func configSpecs() []*RepoSpec { + var specs []*RepoSpec + for _, line := range configRawLines() { + if spec, _ := parseRepoSpec(tokenize(line)); spec != nil { + spec.Raw = line + specs = append(specs, spec) + } + } + return specs +} + +func configLineErrors() []ConfigError { + var errs []ConfigError + for _, line := range configRawLines() { + if spec, msg := parseRepoSpec(tokenize(line)); spec == nil { + if msg == "" { + msg = "unparseable line" + } + errs = append(errs, ConfigError{Label: line, Reason: msg, Detail: line}) + } + } + return errs +} + +func configLoad() ([]*RepoSpec, []ConfigError) { + errs := configLineErrors() + var watchable []*RepoSpec + for _, spec := range configSpecs() { + reason := "" + if _, err := os.Stat(spec.Path); err != nil { + reason = "repo not found" + } else if !isRepo(spec.WorkDir(), spec.GitDir) { + reason = "not a git repo" + } else { + watchable = append(watchable, spec) + continue + } + errs = append(errs, ConfigError{Label: spec.Name(), Reason: reason, + Detail: spec.Path, RepoPath: spec.Path}) + } + return watchable, errs +} + +func configAppend(line string) { + configEnsureExists() + raw, _ := os.ReadFile(configPath()) + text := string(raw) + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n" + } + text += line + "\n" + os.WriteFile(configPath(), []byte(text), 0o644) +} + +func configMatches(spec *RepoSpec, nameOrPath string) bool { + return spec.Path == expandTilde(nameOrPath) || spec.Name() == nameOrPath || spec.Path == nameOrPath +} + +func configRemove(nameOrPath string) int { + raw, err := os.ReadFile(configPath()) + if err != nil { + return 0 + } + removed := 0 + var kept []string + for _, rawLine := range strings.Split(string(raw), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + kept = append(kept, rawLine) + continue + } + spec, _ := parseRepoSpec(tokenize(line)) + if spec != nil && configMatches(spec, nameOrPath) { + removed++ + continue + } + kept = append(kept, rawLine) + } + os.WriteFile(configPath(), []byte(strings.Join(kept, "\n")), 0o644) + return removed +} + +// Flip the --paused token on config lines matching `nameOrPath` (by full +// path or repo name, like remove). Pause lives in the config, not daemon +// state, so it survives daemon and machine restarts. Returns the number +// of lines changed. +func configSetPaused(nameOrPath string, paused bool) int { + raw, err := os.ReadFile(configPath()) + if err != nil { + return 0 + } + changed := 0 + var lines []string + for _, rawLine := range strings.Split(string(raw), "\n") { + trimmed := strings.TrimSpace(rawLine) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + lines = append(lines, rawLine) + continue + } + spec, _ := parseRepoSpec(tokenize(trimmed)) + if spec == nil || !configMatches(spec, nameOrPath) { + lines = append(lines, rawLine) + continue + } + rewritten, ok := togglingPaused(trimmed, spec.Path, paused) + if !ok { + lines = append(lines, rawLine) + continue + } + changed++ + lines = append(lines, rewritten) + } + if changed > 0 { + os.WriteFile(configPath(), []byte(strings.Join(lines, "\n")), 0o644) + } + return changed +} + +func togglingPaused(line string, path string, paused bool) (string, bool) { + tokens := tokenize(line) + spec, _ := parseRepoSpec(tokens) + if spec == nil || spec.Path != path || spec.Paused == paused { + return "", false + } + var kept []string + for _, t := range tokens { + if t != "--paused" { + kept = append(kept, t) + } + } + if paused { + kept = append([]string{"--paused"}, kept...) + } + quoted := make([]string, len(kept)) + for i, t := range kept { + quoted[i] = quoteIfNeeded(t) + } + return strings.Join(quoted, " "), true +} + +func quoteIfNeeded(s string) string { + if strings.Contains(s, `"`) && !strings.Contains(s, "'") { + return "'" + s + "'" + } + if strings.Contains(s, " ") || strings.Contains(s, `"`) || strings.Contains(s, "'") { + return `"` + s + `"` + } + return s +} + +// Split a config line into arguments on whitespace, respecting simple single +// or double quotes so that `-m "two words"` stays a single argument. +func tokenize(line string) []string { + var args []string + var current strings.Builder + var openQuote rune // the quote char we're inside, 0 if none + + finishArg := func() { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + } + + for _, ch := range line { + switch { + case openQuote != 0: + if ch == openQuote { + openQuote = 0 + } else { + current.WriteRune(ch) + } + case ch == '"' || ch == '\'': + openQuote = ch + case unicode.IsSpace(ch): + finishArg() + default: + current.WriteRune(ch) + } + } + finishArg() + return args +} + +// Parse a gitwatch-style argument list into a RepoSpec. +// Returns nil + an error message if there's no valid target. +func parseRepoSpec(args []string) (*RepoSpec, string) { + spec := &RepoSpec{ + Settle: 2, + DateFormat: "+%Y-%m-%d %H:%M:%S", + Message: "gitwatchd auto-commit (%d)", + } + target := "" + haveTarget := false + i := 0 + next := func() (string, bool) { + i++ + if i < len(args) { + return args[i], true + } + return "", false + } + + for i < len(args) { + a := args[i] + switch a { + case "-s": + v, ok := next() + d, err := strconv.ParseFloat(v, 64) + if !ok || err != nil || d < 0 { + return nil, "-s needs a number of seconds, 0 or more" + } + spec.Settle = d + case "-d": + if v, ok := next(); ok { + spec.DateFormat = v + } + case "-r", "-p": // -p: upstream's alias of -r + if v, ok := next(); ok { + spec.Remote = v + } + case "-b": + if v, ok := next(); ok { + spec.Branch = v + } + case "-R": + spec.Rebase = true + case "-m": + if v, ok := next(); ok { + spec.Message = v + } + case "-x": + v, ok := next() + if !ok { + return nil, "-x needs a valid regular expression" + } + if _, err := regexp.Compile(v); err != nil { + return nil, "-x needs a valid regular expression" + } + spec.Exclude = v + case "-M": + spec.NoMergeCommit = true + case "-f": + spec.CommitOnStart = true + case "-g": + if v, ok := next(); ok { + spec.GitDir = v + } + case "-e": + next() // -e accepted for gitwatch compatibility; ignored + case "--paused": + spec.Paused = true + default: + if strings.HasPrefix(a, "-") { + return nil, "unknown flag " + a + } + target = a // last bare arg wins as the target + spec.targetIndex = i + haveTarget = true + } + i++ + } + + if !haveTarget { + return nil, "no target path given" + } + spec.Path = normalizePath(target) + return spec, "" +} + +func normalizePath(p string) string { + p = expandTilde(p) + if !filepath.IsAbs(p) { + cwd, err := os.Getwd() + if err == nil { + p = filepath.Join(cwd, p) + } + } + return filepath.Clean(p) +} + +func expandTilde(p string) string { + if p == "~" { + return homeDir() + } + if strings.HasPrefix(p, "~/") { + return filepath.Join(homeDir(), p[2:]) + } + return p +} + +func homeDir() string { + h, err := os.UserHomeDir() + if err != nil { + return "/" + } + return h +} + +// One status tail at most: paused wins over errors, errors over pending. +func rowTitle(name, branch string, paused bool, pending int, errorLabel string) string { + base := name + " · " + branch + if paused { + return base + " · ⏸ paused" + } + if errorLabel != "" { + return base + " · ⚠ " + errorLabel + } + if pending == 1 { + return base + " · 1 pending change" + } + if pending > 1 { + return fmt.Sprintf("%s · %d pending changes", base, pending) + } + return base +} + +// Fixed-width row; the full path lives on the detail line. +func configErrorRow(label, reason string) string { + return truncated("⚠ "+label+" · "+reason, 48) +} + +func errorHeadline(label string, attempts int) string { + if attempts > 1 { + return fmt.Sprintf("⚠ %s (%d attempts)", label, attempts) + } + return "⚠ " + label +} + +func retryLine(lastTried time.Time, nextRetry *time.Time, now time.Time) string { + tried := "tried " + ago(now.Sub(lastTried).Seconds()) + if nextRetry == nil { + return tried + " · retries on next change" + } + dt := nextRetry.Sub(now).Seconds() + if dt <= 1 { + return tried + " · retrying now" + } + return tried + " · retrying in " + span(dt) +} + +// Autostart = a systemd user unit, the standard way for a per-user daemon to +// survive reboots and headless boots (with lingering). If systemd is absent, +// report this and do nothing (the user should e.g. add `gitwatchd start` to +// a startup script in this case). + +func unitPath() string { + return filepath.Join(homeDir(), ".config", "systemd", "user", "gitwatchd.service") +} + +func systemctlPresent() bool { + _, err := exec.LookPath("systemctl") + return err == nil +} + +func unitInstalled() bool { + _, err := os.Stat(unitPath()) + return err == nil +} + +func systemctlUser(args ...string) (int, string) { + return runCommand("systemctl", append([]string{"--user"}, args...), "") +} + +func unitActive() bool { + _, out := systemctlUser("is-active", "gitwatchd") + return out == "active" +} + +func unitEnabled() bool { + if !unitInstalled() { + return false + } + _, out := systemctlUser("is-enabled", "gitwatchd") + return out == "enabled" +} + +// Returns "" on success, or why not. +func writeAutostartUnit() string { + exe, err := os.Executable() + if err != nil { + return "cannot resolve the gitwatchd binary path: " + err.Error() + } + exe, _ = filepath.EvalSymlinks(exe) + unit := fmt.Sprintf(`[Unit] +Description=gitwatchd: watch git repos and auto-commit changes + +[Service] +ExecStart=%s daemon +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +`, exe) + if err := os.MkdirAll(filepath.Dir(unitPath()), 0o755); err != nil { + return "cannot create " + filepath.Dir(unitPath()) + ": " + err.Error() + } + if err := os.WriteFile(unitPath(), []byte(unit), 0o644); err != nil { + return "cannot write " + unitPath() + ": " + err.Error() + } + systemctlUser("daemon-reload") + return "" +} + +// Lingering keeps the user manager alive without an open session (headless boots). +func enableLinger() string { + code, out := runCommand("loginctl", []string{"enable-linger", os.Getenv("USER")}, "") + if code != 0 { + return strings.TrimSpace(out) + } + return "" +} + +func autostartOn() int { + if !systemctlPresent() { + warn("systemd not found: autostart needs a systemd user session.\n" + + " run the daemon manually instead: gitwatchd start") + return 1 + } + // Recorded before attempting, so a failed enable is retried on a later start. + setLaunchAtLogin(true) + if msg := writeAutostartUnit(); msg != "" { + warn(msg) + return 1 + } + // A directly spawned daemon holds the single-instance lock and would + // make the unit fail; hand it over to systemd. + if isDaemonRunning() && !unitActive() { + if pid := daemonPid(); pid > 0 { + syscall.Kill(pid, syscall.SIGTERM) + waitForDaemonExit(5 * time.Second) + } + } + if code, out := systemctlUser("enable", "--now", "gitwatchd"); code != 0 { + warn("systemctl --user enable --now gitwatchd failed: " + out) + return 1 + } + if note := enableLinger(); note != "" { + fmt.Println("✓ autostart: on (systemd user unit enabled)") + fmt.Println(" note: loginctl enable-linger failed (" + note + ")") + fmt.Println(" without lingering the daemon stops when you log out") + return 0 + } + fmt.Println("✓ autostart: on (systemd user unit enabled, survives logout and reboot)") + return 0 +} + +func autostartOff() int { + setLaunchAtLogin(false) // a standing opt-out: no later run turns it back on + if !systemctlPresent() { + warn("systemd not found: nothing to turn off (autostart was never installed)") + return 1 + } + if !unitInstalled() { + fmt.Println("autostart: already off") + return 0 + } + systemctlUser("disable", "--now", "gitwatchd") + os.Remove(unitPath()) + systemctlUser("daemon-reload") + fmt.Println("✓ autostart: off") + return 0 +} + +func autostartStatus() int { + if !systemctlPresent() { + fmt.Println("autostart: unavailable (systemd not found); run the daemon with: gitwatchd start") + return 0 + } + if !unitInstalled() { + fmt.Println("autostart: off") + return 0 + } + _, enabled := systemctlUser("is-enabled", "gitwatchd") + state := "on" + if enabled != "enabled" { + state = "installed but " + enabled + } + if unitActive() { + fmt.Printf("autostart: %s (daemon running)\n", state) + } else { + fmt.Printf("autostart: %s (daemon not running)\n", state) + } + return 0 +} + +// First-run onboarding: an installed gitwatchd ends up running at boot without anyone asking. + +// A binary outside the three install destinations is a development copy: onboarding leaves it alone. +func isInstalledBinary(exe string) bool { + dir, err := filepath.EvalSymlinks(filepath.Dir(exe)) + if err != nil { + return false + } + for _, root := range []string{"/usr/local/bin", + filepath.Join(homeDir(), ".local", "bin"), filepath.Join(homeDir(), "bin")} { + if resolved, err := filepath.EvalSymlinks(root); err == nil && resolved == dir { + return true + } + } + return false +} + +type autostartConditions struct { + installedBinary bool + recorded bool + wantsOn bool + systemdPresent bool + unitEnabled bool +} + +type autostartAction int + +const ( + autostartLeaveAlone autostartAction = iota + autostartEnableFirstRun + autostartReinstate + autostartReportUnavailable +) + +func autostartActionFor(c autostartConditions) autostartAction { + if !c.installedBinary { + return autostartLeaveAlone + } + if c.recorded && !c.wantsOn { + return autostartLeaveAlone // `autostart off` is never overridden + } + if !c.systemdPresent { + if c.recorded { + return autostartLeaveAlone // said once already, not on every start + } + return autostartReportUnavailable + } + if !c.recorded { + return autostartEnableFirstRun + } + if c.unitEnabled { + return autostartLeaveAlone + } + return autostartReinstate +} + +// No --now: the caller is the running daemon, and a second copy dies on the single-instance lock. +func enableAutostartForNextBoot() string { + if msg := writeAutostartUnit(); msg != "" { + return msg + } + if code, out := systemctlUser("enable", "gitwatchd"); code != 0 { + return "systemctl --user enable gitwatchd failed: " + out + } + enableLinger() // best effort, as for `autostart on` + return "" +} + +// First start of an installed gitwatchd turns autostart on: a daemon that does +// not come back after a reboot is not doing its one job. +func reconcileAutostart() string { + exe, err := os.Executable() + if err != nil { + return "" + } + wantsOn, recorded := launchAtLogin() + conditions := autostartConditions{ + installedBinary: isInstalledBinary(exe), + recorded: recorded, + wantsOn: wantsOn, + systemdPresent: systemctlPresent(), + } + if conditions.systemdPresent { + conditions.unitEnabled = unitEnabled() + } + action := autostartActionFor(conditions) + if action == autostartLeaveAlone { + return "" + } + setLaunchAtLogin(true) // recorded before the outcome, so a failure is retried + if action == autostartReportUnavailable { + return "autostart: unavailable (systemd not found); to have the daemon come back " + + "after a reboot, run `gitwatchd start` from your session startup" + } + if msg := enableAutostartForNextBoot(); msg != "" { + return "autostart could not be enabled: " + msg + } + if action == autostartEnableFirstRun { + return "autostart: on (systemd user unit enabled on first run; " + + "turn it off with `gitwatchd autostart off`)" + } + return "autostart: the systemd user unit had gone missing, re-enabled it" +} diff --git a/linux/cli_test.go b/linux/cli_test.go new file mode 100644 index 0000000..cc5dc22 --- /dev/null +++ b/linux/cli_test.go @@ -0,0 +1,672 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// CLI contract tests run the real cliRun against a scratch config directory +// (never the user's ~/.gitwatchd) with daemon-spawning disabled. + +func withTemporaryConfig(t *testing.T) { + t.Helper() + t.Setenv("GITWATCHD_NO_SPAWN", "1") + dir := t.TempDir() + t.Setenv("GITWATCHD_CONFIG", filepath.Join(dir, "gitwatchd")) + t.Setenv("GITWATCHD_STATE_DIR", filepath.Join(dir, "state")) +} + +func TestAddRefusesAMissingPath(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"add", filepath.Join(t.TempDir(), "no-such-dir")}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be written to the config") + } +} + +func TestAddRefusesANonRepo(t *testing.T) { + withTemporaryConfig(t) + dir := filepath.Join(t.TempDir(), "plain-folder") + os.MkdirAll(dir, 0o755) + if cliRun([]string{"add", dir}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be written to the config") + } +} + +func TestAddPersistsExactlyWhatWasTyped(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + if cliRun([]string{"add", "-s", "5", "-m", "two words", repo.path}) != 0 { + t.Fatal("add failed") + } + lines := configRawLines() + want := `-s 5 -m "two words" ` + repo.path + if len(lines) != 1 || lines[0] != want { + t.Errorf("got %v, want [%q]", lines, want) + } + spec := configSpecs()[0] + if spec.Settle != 5 || spec.Message != "two words" { + t.Errorf("got %+v", spec) + } +} + +func TestAddResolvesARelativeTargetForTheDaemon(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + t.Chdir(repo.path) + if cliRun([]string{"-s", "5", "."}) != 0 { + t.Fatal("add failed") + } + // The daemon reads the config from a different working directory, so + // the stored target must be absolute; the flags stay verbatim. + if got := configRawLines()[0]; got != "-s 5 "+repo.path { + t.Errorf("got %q", got) + } +} + +func TestDuplicateAddFailsAndLeavesOneEntry(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{repo.path}) != 1 { + t.Error("duplicate add should fail") + } + if cliRun([]string{"add", "-s", "5", repo.path}) != 1 { + t.Error("different flags, same repo: still a duplicate") + } + if len(configSpecs()) != 1 { + t.Errorf("got %d entries", len(configSpecs())) + } +} + +func TestBarePathIsAnImplicitAdd(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + if cliRun([]string{repo.path}) != 0 { + t.Fatal("bare add failed") + } + specs := configSpecs() + if len(specs) != 1 || specs[0].Path != repo.path { + t.Errorf("got %+v", specs) + } +} + +func TestRmMatchesByName(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", repo.spec().Name()}) != 0 { + t.Error("rm by name failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmMatchesByFullPath(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", repo.path}) != 0 { + t.Error("rm by path failed") + } + if len(configSpecs()) != 0 { + t.Error("the entry should be gone") + } +} + +func TestRmUnknownFailsAndLeavesConfigAlone(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"rm", "not-a-watched-repo"}) != 1 { + t.Error("expected failure") + } + if len(configSpecs()) != 1 { + t.Error("the config must be untouched") + } +} + +func TestPauseAndResumeFlipPausedInConfig(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{"-s", "5", repo.path}) + if cliRun([]string{"pause", repo.spec().Name()}) != 0 { + t.Fatal("pause failed") + } + if !configSpecs()[0].Paused { + t.Error("spec should be paused") + } + if got := configRawLines()[0]; got != "--paused -s 5 "+repo.path { + t.Errorf("the rest of the line must survive untouched, got %q", got) + } + if cliRun([]string{"resume", repo.spec().Name()}) != 0 { + t.Fatal("resume failed") + } + if configSpecs()[0].Paused { + t.Error("spec should be resumed") + } + if got := configRawLines()[0]; got != "-s 5 "+repo.path { + t.Errorf("got %q", got) + } +} + +func TestPauseByFullPath(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + if cliRun([]string{"pause", repo.path}) != 0 { + t.Fatal("pause failed") + } + if !configSpecs()[0].Paused { + t.Error("spec should be paused") + } +} + +func TestPausingTwiceFailsPolitely(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + cliRun([]string{"pause", repo.path}) + before := configRawLines() + if cliRun([]string{"pause", repo.path}) != 1 { + t.Error("expected failure") + } + after := configRawLines() + if strings.Join(before, "\n") != strings.Join(after, "\n") { + t.Error("the config must be unchanged") + } +} + +func TestPauseUnknownFails(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"pause", "nothing-here"}) != 1 { + t.Error("expected failure") + } +} + +func TestVersionDoesNotFallThroughToAdd(t *testing.T) { + withTemporaryConfig(t) + if cliRun([]string{"version"}) != 0 || cliRun([]string{"--version"}) != 0 { + t.Error("version should succeed") + } + if len(configSpecs()) != 0 { + t.Error("nothing should be added") + } +} + +func TestBrokenConfigEntriesSurfaceInLoadAndStatus(t *testing.T) { + withTemporaryConfig(t) + repo := newTestRepo(t) + cliRun([]string{repo.path}) + configAppend(filepath.Join(t.TempDir(), "vanished-repo")) + configAppend("-z bogus /tmp/x") + specs, errs := configLoad() + if len(specs) != 1 { + t.Errorf("the healthy repo is unaffected, got %d", len(specs)) + } + if len(errs) != 2 { + t.Fatalf("got %d errors", len(errs)) + } + foundMissing, foundUnknown := false, false + for _, e := range errs { + if e.Reason == "repo not found" { + foundMissing = true + } + if strings.Contains(e.Reason, "unknown flag") { + foundUnknown = true + } + } + if !foundMissing || !foundUnknown { + t.Errorf("got %+v", errs) + } + if cliRun([]string{"status"}) != 0 { + t.Error("status renders them rather than crashing or hiding them") + } +} + +func TestPausedParsesAlongsideGitwatchFlags(t *testing.T) { + spec, errMsg := parseRepoSpec([]string{"--paused", "-s", "2", "/tmp/x"}) + if spec == nil { + t.Fatal(errMsg) + } + if !spec.Paused || spec.Settle != 2 { + t.Errorf("got %+v", spec) + } +} + +func TestPauseRewritesAndResumeRestores(t *testing.T) { + line := "-s 2 -r origin -b main -R /tmp/x" + pausedLine, ok := togglingPaused(line, "/tmp/x", true) + if !ok || pausedLine != "--paused -s 2 -r origin -b main -R /tmp/x" { + t.Fatalf("got %q", pausedLine) + } + resumed, ok := togglingPaused(pausedLine, "/tmp/x", false) + if !ok || resumed != line { + t.Errorf("got %q", resumed) + } +} + +func TestQuotedMessagesSurviveTheRewrite(t *testing.T) { + got, ok := togglingPaused(`-m "two words" /tmp/x`, "/tmp/x", true) + if !ok || got != `--paused -m "two words" /tmp/x` { + t.Errorf("got %q", got) + } +} + +func TestEmbeddedQuotesSurviveTheRewrite(t *testing.T) { + got, ok := togglingPaused(`-m 'say "hi"' /tmp/x`, "/tmp/x", true) + if !ok || got != `--paused -m 'say "hi"' /tmp/x` { + t.Errorf("got %q", got) + } + got, ok = togglingPaused(`-m "don't" /tmp/x`, "/tmp/x", true) + if !ok || got != `--paused -m "don't" /tmp/x` { + t.Errorf("got %q", got) + } +} + +func TestOtherLinesAreLeftAlone(t *testing.T) { + if _, ok := togglingPaused("-s 2 /tmp/other", "/tmp/x", true); ok { + t.Error("a line for another repo must not be rewritten") + } +} + +func TestNoOpToggleChangesNothing(t *testing.T) { + if _, ok := togglingPaused("--paused /tmp/x", "/tmp/x", true); ok { + t.Error("already paused: nothing to do") + } + if _, ok := togglingPaused("/tmp/x", "/tmp/x", false); ok { + t.Error("already watching: nothing to do") + } +} + +func TestTokenizeRespectsQuotes(t *testing.T) { + got := tokenize(`-m "two words" -x '\.log$' /tmp/x`) + want := []string{"-m", "two words", "-x", `\.log$`, "/tmp/x"} + if len(got) != len(want) { + t.Fatalf("got %v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("token %d: got %q, want %q", i, got[i], want[i]) + } + } +} + +// Autostart onboarding: what a daemon start does about autostart, given the +// recorded wish, where the binary lives and whether systemd is here. + +func TestAutostartDecisionTable(t *testing.T) { + installed := autostartConditions{installedBinary: true, systemdPresent: true} + cases := []struct { + what string + conditions autostartConditions + want autostartAction + }{ + {"a development copy is never onboarded", + autostartConditions{systemdPresent: true}, autostartLeaveAlone}, + {"a development copy is left alone even with a wish on record", + autostartConditions{recorded: true, wantsOn: true, systemdPresent: true}, autostartLeaveAlone}, + {"the first installed run turns autostart on", + installed, autostartEnableFirstRun}, + {"a wish that is already satisfied needs nothing", + autostartConditions{installedBinary: true, systemdPresent: true, + recorded: true, wantsOn: true, unitEnabled: true}, autostartLeaveAlone}, + {"a wanted unit that went missing is reinstated", + autostartConditions{installedBinary: true, systemdPresent: true, + recorded: true, wantsOn: true}, autostartReinstate}, + {"an opt-out is never overridden", + autostartConditions{installedBinary: true, systemdPresent: true, recorded: true}, + autostartLeaveAlone}, + {"the first installed run without systemd says so", + autostartConditions{installedBinary: true}, autostartReportUnavailable}, + {"without systemd it says so once, not on every start", + autostartConditions{installedBinary: true, recorded: true, wantsOn: true}, + autostartLeaveAlone}, + {"an opt-out without systemd stays quiet", + autostartConditions{installedBinary: true, recorded: true}, autostartLeaveAlone}, + } + for _, c := range cases { + if got := autostartActionFor(c.conditions); got != c.want { + t.Errorf("%s: action %d, want %d (%+v)", c.what, got, c.want, c.conditions) + } + } +} + +func TestAutostartWishIsRecordedAsLaunchAtLogin(t *testing.T) { + withTemporaryConfig(t) + if _, recorded := launchAtLogin(); recorded { + t.Error("nothing is on record until the user or a first run says so") + } + setLaunchAtLogin(true) + if on, recorded := launchAtLogin(); !on || !recorded { + t.Errorf("after recording on: on=%v recorded=%v", on, recorded) + } + setLaunchAtLogin(false) + if on, recorded := launchAtLogin(); on || !recorded { + t.Errorf("after recording off: on=%v recorded=%v", on, recorded) + } + raw, err := os.ReadFile(statePath()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"launch-at-login": "off"`) { + t.Errorf("the setting must be readable under its own key:\n%s", raw) + } +} + +func TestOnlyAnInstalledBinaryIsOnboarded(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + for _, dir := range []string{".local/bin", "bin", "build"} { + os.MkdirAll(filepath.Join(home, dir), 0o755) + } + for _, dir := range []string{".local/bin", "bin"} { + if !isInstalledBinary(filepath.Join(home, dir, "gitwatchd")) { + t.Errorf("%s is one of the install destinations", dir) + } + } + if isInstalledBinary(filepath.Join(home, "build", "gitwatchd")) { + t.Error("a build directory holds a development copy") + } +} + +// The help text states flag defaults and exclusion behaviour as facts; +// these tests pin them so the help can't silently drift from the code. + +func TestDefaultsForBarePath(t *testing.T) { + spec, errMsg := parseRepoSpec([]string{"/tmp/x"}) + if spec == nil { + t.Fatal(errMsg) + } + if spec.Settle != 2 { // -s "Default: 2" + t.Errorf("settle = %v", spec.Settle) + } + if spec.Remote != "" { // -r "Default: no push" + t.Errorf("remote = %q", spec.Remote) + } + if spec.Message != "gitwatchd auto-commit (%d)" { + t.Errorf("message = %q", spec.Message) + } + if spec.DateFormat != "+%Y-%m-%d %H:%M:%S" { // upstream's exact default, leading + included + t.Errorf("dateFormat = %q", spec.DateFormat) + } + if spec.Branch != "" || spec.Rebase || spec.Exclude != "" || spec.NoMergeCommit || spec.Paused { + t.Errorf("unexpected non-default: %+v", spec) + } + if spec.CommitOnStart { + t.Error("-f is opt-in: a deliberate manual state is not flushed") + } +} + +func TestPIsAnAliasOfR(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-p", "origin", "/tmp/x"}) + if spec == nil || spec.Remote != "origin" { + t.Errorf("got %+v", spec) + } +} + +func TestSettleRejectsBadValues(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); spec != nil { + t.Error("-s -1 should be rejected") + } + if spec, _ := parseRepoSpec([]string{"-s", "soon", "/tmp/x"}); spec != nil { + t.Error("-s soon should be rejected") + } + if _, msg := parseRepoSpec([]string{"-s", "-1", "/tmp/x"}); msg != "-s needs a number of seconds, 0 or more" { + t.Errorf("wrong message: %q", msg) + } + spec, _ := parseRepoSpec([]string{"-s", "0", "/tmp/x"}) + if spec == nil || spec.Settle != 0 { + t.Error("-s 0 is valid") + } +} + +func TestExcludeMatchesAnywhereInPath(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/deep/dir/debug.log") { + t.Error("should match a nested .log file") + } + if spec.Excludes("/tmp/x/log.txt") { + t.Error("should not match log.txt") + } +} + +func TestExcludeDirectoryPattern(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", "build/", "/tmp/x"}) + if !spec.Excludes("/tmp/x/build/out.o") { + t.Error("should exclude the build subtree") + } + if spec.Excludes("/tmp/x/src/out.o") { + t.Error("should not exclude src") + } +} + +func TestLastExcludeWins(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-x", `\.log$`, "-x", `\.tmp$`, "/tmp/x"}) + if !spec.Excludes("/tmp/x/b.tmp") { + t.Error("the last -x should apply") + } + if spec.Excludes("/tmp/x/a.log") { + t.Error("the first -x should be forgotten, as upstream") + } +} + +func TestInvalidExcludeIsAParseError(t *testing.T) { + if spec, _ := parseRepoSpec([]string{"-x", "*.log", "/tmp/x"}); spec != nil { + t.Error("an invalid regex must be a parse error, not a silent no-op") + } +} + +func TestNoExcludeByDefault(t *testing.T) { + spec, _ := parseRepoSpec([]string{"/tmp/x"}) + if spec.Excludes("/tmp/x/anything.log") { + t.Error("nothing is excluded without -x") + } +} + +// The help is the contract; these hold it to the implementation. The +// canonical lists below ARE the documented surface: adding a flag or command +// to only one side of the contract fails one of these tests. + +var contractValueFlags = map[string]string{ + "-s": "2", "-r": "origin", "-b": "main", "-m": "msg", + "-d": "+%Y", "-x": `\.log$`, "-g": "/tmp/gd", +} + +var contractBoolFlags = []string{"-R", "-M", "-f", "--paused"} +var contractCommands = []string{"add", "rm", "pause", "resume", "status", + "start", "stop", "autostart", "config", "help", "version"} + +func TestDocumentedFlagsParse(t *testing.T) { + for flag, value := range contractValueFlags { + if spec, msg := parseRepoSpec([]string{flag, value, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } + for _, flag := range contractBoolFlags { + if spec, msg := parseRepoSpec([]string{flag, "/tmp/x"}); spec == nil { + t.Errorf("%s is documented but doesn't parse: %s", flag, msg) + } + } +} + +func TestContractAppearsInHelp(t *testing.T) { + for flag := range contractValueFlags { + if !strings.Contains(usageText, flag+" <") { + t.Errorf("help is missing %s with its ", flag) + } + } + for _, flag := range contractBoolFlags { + if !strings.Contains(usageText, flag) { + t.Errorf("help is missing %s", flag) + } + } + for _, command := range contractCommands { + if !strings.Contains(usageText, command) { + t.Errorf("help is missing the %s command", command) + } + } +} + +func TestNoPhantomFlagsInHelp(t *testing.T) { + known := map[string]bool{} + for flag := range contractValueFlags { + known[flag] = true + } + for _, flag := range contractBoolFlags { + known[flag] = true + } + for _, line := range strings.Split(usageText, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "-") { + continue + } + flag := strings.Fields(line)[0] + if !known[flag] { + t.Errorf("help documents %s, which the contract list doesn't know", flag) + } + } +} + +// What `status` shows, asserted as exact strings: the same rows the macOS +// menu renders, so status reads identically across machines. + +func TestHealthyRowIsNameAndBranch(t *testing.T) { + if got := rowTitle("notes", "main", false, 0, ""); got != "notes · main" { + t.Errorf("got %q", got) + } +} + +func TestPendingEditsShowACount(t *testing.T) { + if got := rowTitle("notes", "main", false, 3, ""); got != "notes · main · 3 pending changes" { + t.Errorf("got %q", got) + } + if got := rowTitle("notes", "main", false, 1, ""); got != "notes · main · 1 pending change" { + t.Errorf("got %q", got) + } +} + +func TestErrorLabelFlagsTheRow(t *testing.T) { + if got := rowTitle("notes", "main", false, 0, "push failing"); got != "notes · main · ⚠ push failing" { + t.Errorf("got %q", got) + } +} + +func TestOutcomeLabels(t *testing.T) { + cases := map[CommitOutcome]string{ + PushFailed: "push failing", + RebaseConflict: "rebase conflict", + CommitFailed: "commit failing", + Pushed: "", + } + for kind, want := range cases { + if got := (Outcome{Kind: kind, Detail: "x"}).ErrorLabel(); got != want { + t.Errorf("kind %v: got %q, want %q", kind, got, want) + } + } +} + +func TestConfigErrorRow(t *testing.T) { + if got := configErrorRow("demo-repo", "repo not found"); got != "⚠ demo-repo · repo not found" { + t.Errorf("got %q", got) + } + longLabel := "" + for i := 0; i < 100; i++ { + longLabel += "y" + } + if got := configErrorRow(longLabel, "not a git repo"); len([]rune(got)) != 48 { + t.Errorf("rows stay fixed width; got %d runes", len([]rune(got))) + } +} + +func TestPausedBeatsEveryOtherTail(t *testing.T) { + if got := rowTitle("notes", "main", true, 3, "push failing"); got != "notes · main · ⏸ paused" { + t.Errorf("got %q", got) + } +} + +func TestErrorHeadlineCountsAttempts(t *testing.T) { + if got := errorHeadline("push failing", 1); got != "⚠ push failing" { + t.Errorf("got %q", got) + } + if got := errorHeadline("push failing", 4); got != "⚠ push failing (4 attempts)" { + t.Errorf("got %q", got) + } +} + +func TestRetryLine(t *testing.T) { + now := time.Unix(1_000_000, 0) + next := now.Add(180 * time.Second) + if got := retryLine(now.Add(-120*time.Second), &next, now); got != "tried 2m ago · retrying in 3m" { + t.Errorf("got %q", got) + } +} + +func TestRetryLineWithoutTimer(t *testing.T) { + now := time.Unix(1_000_000, 0) + if got := retryLine(now.Add(-30*time.Second), nil, now); got != "tried 30s ago · retries on next change" { + t.Errorf("got %q", got) + } +} + +func TestTruncation(t *testing.T) { + long := "" + for i := 0; i < 100; i++ { + long += "x" + } + if got := truncated(long, 20); len([]rune(got)) != 20 { + t.Errorf("got %d runes", len([]rune(got))) + } + if got := truncated("short", 20); got != "short" { + t.Errorf("got %q", got) + } +} + +func TestBackoffSchedule(t *testing.T) { + want := []time.Duration{30 * time.Second, 60 * time.Second, 120 * time.Second, + 240 * time.Second, 480 * time.Second, 960 * time.Second, + 30 * time.Minute, 30 * time.Minute} + for i, w := range want { + if got := backoffDelay(i + 1); got != w { + t.Errorf("after %d failures: got %v, want %v", i+1, got, w) + } + } + // A day-long outage: the doubling must saturate at the cap, never overflow. + for _, n := range []int{30, 288, 100000} { + if got := backoffDelay(n); got != backoffCap { + t.Errorf("after %d failures: got %v, want the %v cap", n, got, backoffCap) + } + } +} + +func TestErrorSummaryPicksRejectionLine(t *testing.T) { + out := `To /tmp/origin.git + ! [rejected] main -> main (fetch first) +error: failed to push some refs to '/tmp/origin.git' +hint: Updates were rejected because the remote contains work that you do not have` + if got := errorSummary(out); got != "! [rejected] main -> main (fetch first)" { + t.Errorf("got %q", got) + } +} + +func TestErrorSummaryPicksFatalLine(t *testing.T) { + out := "fatal: unable to access 'https://x/': Could not resolve host\nsome trailer" + if got := errorSummary(out); got != "fatal: unable to access 'https://x/': Could not resolve host" { + t.Errorf("got %q", got) + } +} + +func TestErrorSummaryFallsBackToLastLine(t *testing.T) { + if got := errorSummary("lint says no"); got != "lint says no" { + t.Errorf("got %q", got) + } +} diff --git a/linux/daemon.go b/linux/daemon.go new file mode 100644 index 0000000..5ff8891 --- /dev/null +++ b/linux/daemon.go @@ -0,0 +1,613 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "math" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + "unsafe" +) + +// GITWATCHD_STATE_DIR overrides, which is how tests get a daemon of their own. +func stateDir() string { + if d := os.Getenv("GITWATCHD_STATE_DIR"); d != "" { + return d + } + base := os.Getenv("XDG_STATE_HOME") + if base == "" { + base = filepath.Join(homeDir(), ".local", "state") + } + return filepath.Join(base, "gitwatchd") +} + +func statePath() string { return filepath.Join(stateDir(), "state.json") } +func stateLockPath() string { return filepath.Join(stateDir(), "state.lock") } +func pidfilePath() string { return filepath.Join(stateDir(), "gitwatchd.pid") } +func logfilePath() string { return filepath.Join(stateDir(), "daemon.log") } + +// The event set gitwatch passes to inotifywait: +// close_write,move,move_self,delete,create,modify. +const watchMask = syscall.IN_CLOSE_WRITE | syscall.IN_MOVED_FROM | syscall.IN_MOVED_TO | + syscall.IN_MOVE_SELF | syscall.IN_DELETE | syscall.IN_DELETE_SELF | + syscall.IN_CREATE | syscall.IN_MODIFY + +// A repo's current error, as published by the daemon. Watchers write it, and +// `gitwatchd status` is a view over it. +type RepoStatus struct { + ErrorLabel string `json:"errorLabel"` + Detail string `json:"detail,omitempty"` + Attempts int `json:"attempts"` + LastAttempt int64 `json:"lastAttempt"` + NextRetry *int64 `json:"nextRetry,omitempty"` +} + +// Everything gitwatchd persists; a healthy repo has no entry under "repos". +// The shape is the cross-platform contract (names match macos/Sources/StateDB.swift), +// pretty-printed in stable key order because people read and diff it. +type persistedState struct { + LaunchAtLogin string `json:"launch-at-login,omitempty"` // "on", "off", absent: never asked + Repos map[string]RepoStatus `json:"repos,omitempty"` +} + +// Lock-free: writes land by rename. Absent or unreadable state reads as empty. +func readState() persistedState { + raw, err := os.ReadFile(statePath()) + if err != nil { + return persistedState{} + } + var s persistedState + if json.Unmarshal(raw, &s) != nil { + return persistedState{} + } + return s +} + +// Daemon and CLI both rewrite the whole file; the flock stops one from writing +// off a stale read. The lock is its own file, never state.json: renaming over a +// locked file leaves the holder pinning an unlinked inode while the next writer +// locks its replacement. +func updateState(change func(*persistedState)) { + os.MkdirAll(stateDir(), 0o755) + lock, err := os.OpenFile(stateLockPath(), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return + } + defer lock.Close() + if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX) != nil { + return + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + + s := readState() + change(&s) + writeState(s) +} + +func writeState(s persistedState) { + raw, err := json.MarshalIndent(s, "", " ") + if err != nil { + return + } + raw = append(raw, '\n') + tmp := statePath() + ".tmp" + if os.WriteFile(tmp, raw, 0o644) == nil { + os.Rename(tmp, statePath()) + } +} + +func repoStatuses() map[string]RepoStatus { + statuses := readState().Repos + if statuses == nil { + return map[string]RepoStatus{} + } + return statuses +} + +func setRepoStatus(repoPath string, status *RepoStatus) { + updateState(func(s *persistedState) { + if status == nil { + delete(s.Repos, repoPath) + return + } + if s.Repos == nil { + s.Repos = map[string]RepoStatus{} + } + s.Repos[repoPath] = *status + }) +} + +func keepOnlyRepos(watched map[string]bool) { + updateState(func(s *persistedState) { + for path := range s.Repos { + if !watched[path] { + delete(s.Repos, path) + } + } + }) +} + +func launchAtLogin() (on bool, recorded bool) { + value := readState().LaunchAtLogin + return value != "off", value != "" +} + +func setLaunchAtLogin(on bool) { + value := "off" + if on { + value = "on" + } + updateState(func(s *persistedState) { s.LaunchAtLogin = value }) +} + +// One watcher per watchable repo, kept in step with the config file as it is edited. +type daemon struct { + watchers map[string]*repoWatcher // by repo path + previousSpecs map[string]*RepoSpec // the last config seen, paused entries included + logf func(format string, args ...any) +} + +func runDaemon() int { + lock, err := acquireDaemonLock() + if err != nil { + fmt.Fprintln(os.Stderr, "✗ daemon already running") + return 1 + } + defer lock.Close() + + d := &daemon{ + watchers: map[string]*repoWatcher{}, + previousSpecs: map[string]*RepoSpec{}, + logf: func(format string, args ...any) { + fmt.Printf(format+"\n", args...) + }, + } + d.logf("gitwatchd %s: watching config %s", version, configPath()) + if msg := reconcileAutostart(); msg != "" { + d.logf("%s", msg) + } + d.reloadConfig() + + configChanged := make(chan struct{}, 1) + go watchConfigFile(configChanged) + go func() { + for range configChanged { + time.Sleep(300 * time.Millisecond) // let editors finish the save + // Whatever else the save queued is covered by this one reload. + select { + case <-configChanged: + default: + } + d.reloadConfig() + } + }() + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + <-signals + for _, w := range d.watchers { + w.stopWatching() + } + d.logf("gitwatchd stopped") + return 0 +} + +func (d *daemon) reloadConfig() { + specs, errs := configLoad() + for _, e := range errs { + d.logf("config: %s: %s", e.Label, e.Reason) + } + + desired := map[string]*RepoSpec{} + watched := map[string]bool{} + for _, s := range specs { + desired[s.Path] = s + watched[s.Path] = true + } + keepOnlyRepos(watched) + + for path, w := range d.watchers { + s, wanted := desired[path] + if wanted && !s.Paused && s.Raw == w.spec.Raw { + continue + } + w.stopWatching() + delete(d.watchers, path) + d.logf("stopped watching %s", w.spec.Name()) + } + + for path, s := range desired { + if s.Paused { + continue + } + if _, running := d.watchers[path]; running { + continue + } + w, err := newRepoWatcher(s, setRepoStatus, d.logf) + if err != nil { + d.logf("could not watch %s: %v", s.Name(), err) + continue + } + d.watchers[path] = w + w.publishStatus() // surface watch-registration problems immediately + d.logf("watching %s (%s)", s.Name(), s.Path) + + prev, existed := d.previousSpecs[path] + resumed := existed && prev.Paused + if s.CommitOnStart || resumed { + w.flushNow() // -f, or a resume catching up on what piled up + } + } + + d.previousSpecs = desired +} + +// Watch the directory, not the file: editors replace the file on save. +func watchConfigFile(changed chan struct{}) { + fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) + if err != nil { + return + } + if _, err := syscall.InotifyAddWatch(fd, filepath.Dir(configPath()), watchMask); err != nil { + syscall.Close(fd) + return + } + configFile := filepath.Base(configPath()) + readInotifyEvents(fd, func(_ int, _ uint32, name string) { + if name == configFile { + queueWakeup(changed) + } + }) +} + +// Each event is a fixed header plus Len bytes of NUL-padded name; the buffer +// must be big enough to hold a whole one. +func readInotifyEvents(fd int, onEvent func(watchDescriptor int, mask uint32, name string)) { + buf := make([]byte, 64*1024) + for { + n, err := syscall.Read(fd, buf) + if err != nil || n <= 0 { + return // the fd was closed + } + for offset := 0; offset+syscall.SizeofInotifyEvent <= n; { + event := (*syscall.InotifyEvent)(unsafe.Pointer(&buf[offset])) + name := "" + if event.Len > 0 { + start := offset + syscall.SizeofInotifyEvent + name = string(bytes.TrimRight(buf[start:start+int(event.Len)], "\x00")) + } + offset += syscall.SizeofInotifyEvent + int(event.Len) + onEvent(int(event.Wd), event.Mask, name) + } + } +} + +// A wakeup already queued covers this one: the receiver reads current state. +func queueWakeup(ch chan struct{}) { + select { + case ch <- struct{}{}: + default: + } +} + +// Single-instance guard: the daemon holds an exclusive flock on the pidfile +func acquireDaemonLock() (*os.File, error) { + os.MkdirAll(stateDir(), 0o755) + f, err := os.OpenFile(pidfilePath(), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, err + } + f.Truncate(0) + f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0) + return f, nil +} + +func isDaemonRunning() bool { + f, err := os.Open(pidfilePath()) + if err != nil { + return false + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_SH|syscall.LOCK_NB); err != nil { + return true // the daemon holds the exclusive lock + } + syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + return false +} + +func daemonPid() int { + raw, err := os.ReadFile(pidfilePath()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) + return pid +} + +type repoError struct { + outcome Outcome + lastAttempt time.Time + attempts int // consecutive failures + nextRetry *time.Time // nil: no auto retry, waits for a change +} + +// Recursive inotify watcher for one repo: gitwatch's -s debounce and -x excludes, +// plus .git-churn filtering so our own commits don't retrigger. Its reliability +// layer (error state, push retries with backoff) never changes what a commit +// cycle does; it only re-runs the push stage of one that failed. +type repoWatcher struct { + spec *RepoSpec + + inotifyFD int + dirByWatchDescriptor map[int]string + watchMu sync.Mutex // guards dirByWatchDescriptor and watchFailure + + fileChanges chan struct{} + flushRequests chan struct{} + stopping chan struct{} + stopped chan struct{} + + lastError *repoError + watchFailure string // e.g. the inotify watch limit; surfaced, never fatal + + // Called after every cycle; nil while healthy. The daemon publishes it for `status`. + onStatus func(path string, status *RepoStatus) + logf func(format string, args ...any) +} + +func newRepoWatcher(spec *RepoSpec, onStatus func(string, *RepoStatus), + logf func(string, ...any)) (*repoWatcher, error) { + fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC) + if err != nil { + return nil, err + } + w := &repoWatcher{ + spec: spec, + inotifyFD: fd, + dirByWatchDescriptor: map[int]string{}, + fileChanges: make(chan struct{}, 64), + flushRequests: make(chan struct{}, 1), + stopping: make(chan struct{}), + stopped: make(chan struct{}), + onStatus: onStatus, + logf: logf, + } + if spec.IsFileTarget() { + // Watch the parent directory and filter to the file's name, so + // atomic saves (write temp + rename) keep triggering. + w.watchDirectory(filepath.Dir(spec.Path)) + } else { + w.watchTree(spec.Path) + } + go readInotifyEvents(fd, w.handleInotifyEvent) + go w.runCycles() + return w, nil +} + +// Register a watch, surfacing inotify limit exhaustion as repo state +// instead of crashing (fire-and-forget, like every other runtime failure). +func (w *repoWatcher) watchDirectory(dir string) { + wd, err := syscall.InotifyAddWatch(w.inotifyFD, dir, watchMask) + if err != nil { + w.recordWatchFailure(dir, err) + return + } + w.watchMu.Lock() + w.dirByWatchDescriptor[wd] = dir + w.watchMu.Unlock() +} + +func (w *repoWatcher) recordWatchFailure(dir string, err error) { + w.watchMu.Lock() + defer w.watchMu.Unlock() + if err == syscall.ENOSPC { + w.watchFailure = "inotify watch limit reached: raise fs.inotify.max_user_watches " + + "(sudo sysctl fs.inotify.max_user_watches=524288)" + } else if !os.IsNotExist(err) { + w.watchFailure = "watch failed for " + dir + ": " + err.Error() + } +} + +// inotify watches one directory each, so a repo means a watch per directory. +func (w *repoWatcher) watchTree(root string) { + filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil + } + if d.Name() == ".git" { + return filepath.SkipDir + } + w.watchDirectory(path) + return nil + }) +} + +func (w *repoWatcher) handleInotifyEvent(watchDescriptor int, mask uint32, name string) { + if mask&syscall.IN_Q_OVERFLOW != 0 { + queueWakeup(w.fileChanges) // events were dropped; a cycle will catch whatever changed + return + } + w.watchMu.Lock() + dir, known := w.dirByWatchDescriptor[watchDescriptor] + if mask&syscall.IN_IGNORED != 0 { + delete(w.dirByWatchDescriptor, watchDescriptor) + } + w.watchMu.Unlock() + if !known { + return + } + + path := dir + if name != "" { + path = filepath.Join(dir, name) + } + + if w.spec.IsFileTarget() { + // The watch sits on the parent dir; only the target file counts. + if path != w.spec.Path { + return + } + queueWakeup(w.fileChanges) + return + } + + if strings.Contains(path, "/.git/") || strings.HasSuffix(path, "/.git") { + return + } + if w.spec.Excludes(path) { + return + } + if mask&syscall.IN_ISDIR != 0 && mask&(syscall.IN_CREATE|syscall.IN_MOVED_TO) != 0 { + w.watchTree(path) + } + queueWakeup(w.fileChanges) +} + +// Run one cycle soon regardless of file events (-f at start, resume catch-up). +func (w *repoWatcher) flushNow() { + queueWakeup(w.flushRequests) +} + +func (w *repoWatcher) runCycles() { + defer close(w.stopped) + debounce := time.NewTimer(time.Hour) + debounce.Stop() + retry := time.NewTimer(time.Hour) + retry.Stop() + settle := time.Duration(w.spec.Settle * float64(time.Second)) + for { + select { + case <-w.fileChanges: + // Each change resets the settle timer, so a burst of writes + // lands as one commit (gitwatch's sleep-and-kill loop). + debounce.Reset(settle) + case <-debounce.C: + w.report(autoCommit(w.spec), retry) + case <-w.flushRequests: + w.report(autoCommit(w.spec), retry) + case <-retry.C: + if w.lastError != nil { + w.report(push(w.spec), retry) + } + case <-w.stopping: + debounce.Stop() + retry.Stop() + return + } + } +} + +// Push retry backoff: 30s doubling to a 30 minute cap. +const ( + backoffFirst = 30 * time.Second + backoffCap = 30 * time.Minute +) + +func backoffDelay(afterFailures int) time.Duration { + if afterFailures <= 1 { + return backoffFirst + } + // Compare before converting: past ~30 failures the product overflows + // time.Duration and the conversion result is negative. + d := float64(backoffFirst) * math.Pow(2, float64(afterFailures-1)) + if d > float64(backoffCap) { + return backoffCap + } + return time.Duration(d) +} + +// Fold an outcome into the error state, schedule the next automatic retry, +// and publish. A Clean pass says nothing about an earlier failed push (that +// commit is still unpushed), so it leaves the error and its retry timer alone. +func (w *repoWatcher) report(outcome Outcome, retry *time.Timer) { + switch outcome.Kind { + case Committed, Pushed: + retry.Stop() + w.lastError = nil + case Clean, SkippedMerge: + // no change + case PushFailed: + attempts := 1 + if w.lastError != nil { + attempts = w.lastError.attempts + 1 + } + delay := backoffDelay(attempts) + next := time.Now().Add(delay) + w.lastError = &repoError{outcome: outcome, lastAttempt: time.Now(), + attempts: attempts, nextRetry: &next} + retry.Reset(delay) + case RebaseConflict, CommitFailed: + retry.Stop() + attempts := 1 + if w.lastError != nil { + attempts = w.lastError.attempts + 1 + } + w.lastError = &repoError{outcome: outcome, lastAttempt: time.Now(), + attempts: attempts} + } + w.publishStatus() + w.logOutcome(outcome) +} + +func (w *repoWatcher) publishStatus() { + w.watchMu.Lock() + watchFailure := w.watchFailure + w.watchMu.Unlock() + + var status *RepoStatus + if w.lastError != nil { + status = &RepoStatus{ + ErrorLabel: w.lastError.outcome.ErrorLabel(), + Detail: w.lastError.outcome.Detail, + Attempts: w.lastError.attempts, + LastAttempt: w.lastError.lastAttempt.Unix(), + } + if status.ErrorLabel == "" { + status.ErrorLabel = "failing" + } + if w.lastError.nextRetry != nil { + ts := w.lastError.nextRetry.Unix() + status.NextRetry = &ts + } + } else if watchFailure != "" { + status = &RepoStatus{ErrorLabel: "watch failing", Detail: watchFailure, + Attempts: 1, LastAttempt: time.Now().Unix()} + } + w.onStatus(w.spec.Path, status) +} + +func (w *repoWatcher) logOutcome(outcome Outcome) { + name := w.spec.Name() + switch outcome.Kind { + case Committed: + w.logf("%s: committed", name) + case Pushed: + w.logf("%s: pushed to %s", name, w.spec.Remote) + case SkippedMerge: + w.logf("%s: merge in progress, commit skipped", name) + case CommitFailed: + w.logf("%s: commit failing: %s", name, outcome.Detail) + case RebaseConflict: + w.logf("%s: rebase conflict: %s", name, outcome.Detail) + case PushFailed: + w.logf("%s: push failing: %s", name, outcome.Detail) + } +} + +func (w *repoWatcher) stopWatching() { + close(w.stopping) + syscall.Close(w.inotifyFD) // ends the event reader's blocking read + <-w.stopped +} diff --git a/linux/daemon_test.go b/linux/daemon_test.go new file mode 100644 index 0000000..c138134 --- /dev/null +++ b/linux/daemon_test.go @@ -0,0 +1,569 @@ +package main + +import ( + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// The built gitwatchd binary, for tests that exercise the real daemon. +var testBinary string + +func TestMain(m *testing.M) { + if os.Getenv(stubbornDaemonEnv) != "" { + holdTheDaemonLockIgnoringSIGTERM() + } + dir, err := os.MkdirTemp("", "gitwatchd-bin") + if err == nil { + bin := filepath.Join(dir, "gitwatchd") + build := exec.Command("go", "build", "-o", bin, ".") + build.Stderr = os.Stderr + if build.Run() == nil { + testBinary = bin + } + } + code := m.Run() + if dir != "" { + os.RemoveAll(dir) + } + os.Exit(code) +} + +func runCLI(env []string, args ...string) (int, string) { + return runBinary(testBinary, env, args...) +} + +func runBinary(binary string, env []string, args ...string) (int, string) { + cmd := exec.Command(binary, args...) + cmd.Env = env + out, err := cmd.CombinedOutput() + code := 0 + if exit, ok := err.(*exec.ExitError); ok { + code = exit.ExitCode() + } else if err != nil { + code = -1 + } + return code, strings.TrimSpace(string(out)) +} + +func isolatedEnv(home string) []string { + return append(os.Environ(), + "HOME="+home, + "GITWATCHD_CONFIG="+filepath.Join(home, ".gitwatchd"), + "GITWATCHD_STATE_DIR="+filepath.Join(home, "state"), + "GITWATCHD_NO_SPAWN=1") +} + +func TestDaemonEndToEnd(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := isolatedEnv(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + + if code, out := runCLI(env, "start"); code != 0 || !strings.Contains(out, "✓ daemon started") { + t.Fatalf("start: code=%d out=%s", code, out) + } + if code, out := runCLI(env, "start"); code != 0 || !strings.Contains(out, "daemon already running") { + t.Fatalf("second start: code=%d out=%s", code, out) + } + _, out := runCLI(env, "status") + if !strings.Contains(out, "daemon: running") { + t.Fatalf("status should show the daemon running:\n%s", out) + } + + repo.write("notes.txt", "hello\n") + waitFor(t, 15*time.Second, "the daemon's auto-commit", func() bool { return repo.commitCount() == 1 }) + if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { + t.Errorf("got message %q", repo.lastMessage()) + } + time.Sleep(1 * time.Second) + if repo.commitCount() != 1 { + t.Error("the daemon retriggered on its own commit") + } + + _, out = runCLI(env, "status") + if !strings.Contains(out, "repo · main") { + t.Errorf("status should show the watched row:\n%s", out) + } + + second := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", second.path); code != 0 { + t.Fatalf("second add failed: %s", out) + } + waitForDaemonPickup(t, second, func() { second.write("more.txt", "hi\n") }) + + if code, out := runCLI(env, "pause", repo.path); code != 0 { + t.Fatalf("pause failed: %s", out) + } + time.Sleep(700 * time.Millisecond) // let the reload land + repo.write("while-paused.txt", "quiet\n") + time.Sleep(1200 * time.Millisecond) + if repo.commitCount() != 1 { + t.Error("a paused repo must not commit") + } + if code, out := runCLI(env, "resume", repo.path); code != 0 { + t.Fatalf("resume failed: %s", out) + } + waitFor(t, 15*time.Second, "the resume catch-up commit", func() bool { return repo.commitCount() == 2 }) + + if code, out := runCLI(env, "stop"); code != 0 || !strings.Contains(out, "✓ daemon stopped") { + t.Fatalf("stop: code=%d out=%s", code, out) + } + _, out = runCLI(env, "status") + if !strings.Contains(out, "daemon: not running") { + t.Errorf("status should show the daemon stopped:\n%s", out) + } +} + +// The config reload debounces for 300ms; retry the first write until the +// daemon demonstrably watches the new repo. +func waitForDaemonPickup(t *testing.T, repo *testRepo, change func()) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + change() + for time.Now().Before(deadline) { + if repo.commitCount() >= 1 { + return + } + time.Sleep(500 * time.Millisecond) + change() + } + t.Fatal("the daemon never picked up the newly added repo") +} + +// The one production path the in-process CLI tests cannot reach: `add` +// finding no daemon and spawning one off its own binary (in-process, +// os.Executable() is the go-test binary). Runs without GITWATCHD_NO_SPAWN. +func TestAddSpawnsTheDaemon(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + var env []string + for _, e := range isolatedEnv(home) { + if !strings.HasPrefix(e, "GITWATCHD_NO_SPAWN=") { + env = append(env, e) + } + } + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + waitFor(t, 15*time.Second, "the daemon add spawned", func() bool { + _, out := runCLI(env, "status") + return strings.Contains(out, "daemon: running") + }) + pid := daemonPidIn(home) + if pid <= 0 { + t.Fatal("the spawned daemon wrote no pidfile") + } + + if code, out := runCLI(env, "stop"); code != 0 || !strings.Contains(out, "✓ daemon stopped") { + t.Fatalf("stop: code=%d out=%s", code, out) + } + if err := syscall.Kill(pid, 0); err == nil { + t.Errorf("the spawned daemon (pid %d) is still alive after stop", pid) + } +} + +// This test binary re-executed: holds the pidfile lock and ignores SIGTERM. +const stubbornDaemonEnv = "GITWATCHD_TEST_STUBBORN_DAEMON" + +func holdTheDaemonLockIgnoringSIGTERM() { + signal.Ignore(syscall.SIGTERM) + os.MkdirAll(stateDir(), 0o755) + f, err := os.OpenFile(pidfilePath(), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil || syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { + os.Exit(1) + } + f.Truncate(0) + f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0) + time.Sleep(60 * time.Second) // outlives the test; stop is meant to end this + os.Exit(0) +} + +func TestStopKillsADaemonThatIgnoresSIGTERM(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + stubborn := exec.Command(os.Args[0]) + stubborn.Env = append(isolatedEnv(home), stubbornDaemonEnv+"=1") + if err := stubborn.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { stubborn.Process.Kill() }) + // The pid is written after the lock is taken, so a pid means it is held. + waitFor(t, 10*time.Second, "the stubborn daemon to take the lock", func() bool { + return daemonPidIn(home) > 0 + }) + + code, out := runCLI(isolatedEnv(home), "stop") + if code != 0 { + t.Fatalf("stop must still succeed: code=%d out=%s", code, out) + } + if !strings.Contains(out, "✓ daemon stopped") || !strings.Contains(out, "ignored the stop signal") { + t.Errorf("stop must say what it took to stop it:\n%s", out) + } + exit, killed := stubborn.Wait().(*exec.ExitError) + if !killed { + t.Fatal("the stubborn daemon outlived stop") + } + if status, ok := exit.Sys().(syscall.WaitStatus); !ok || status.Signal() != syscall.SIGKILL { + t.Errorf("expected SIGKILL, got %v", exit) + } +} + +func TestAutostartDegradesClearlyWithoutSystemd(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + if _, err := os.Stat("/run/systemd/system"); err == nil { + t.Skip("this host runs systemd; the degrade path is exercised in plain containers") + } + home := t.TempDir() + env := envWithoutSystemctl(home) + code, out := runCLI(env, "autostart", "on") + if code == 0 { + t.Error("autostart on must fail without systemd") + } + if !strings.Contains(out, "systemd not found") || !strings.Contains(out, "gitwatchd start") { + t.Errorf("the message must explain and point to `gitwatchd start`:\n%s", out) + } + if _, err := os.Stat(filepath.Join(home, ".config", "systemd")); err == nil { + t.Error("no unit may be written when systemd is absent") + } + if entries, _ := os.ReadDir(home); len(entries) > 2 { // bin/ and nothing else unexpected + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("no dotfiles may be touched, found %v", names) + } +} + +func TestDaemonFromABuildDirectoryLeavesAutostartAlone(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := envWithoutSystemctl(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + + repo := newTestRepo(t) + if code, out := runCLI(env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + if code, out := runCLI(env, "start"); code != 0 { + t.Fatalf("start: code=%d out=%s", code, out) + } + // Reconcile runs before the first repo is watched, so this log line means it is done. + waitFor(t, 15*time.Second, "the daemon to watch the repo", func() bool { + return strings.Contains(daemonLogIn(home), "watching "+filepath.Base(repo.path)+" (") + }) + if strings.Contains(stateFileIn(home), "launch-at-login") { + t.Error("a development copy must record no wish") + } + if _, err := os.Stat(filepath.Join(home, ".config")); err == nil { + t.Error("a development copy must write no unit") + } + if code, out := runCLI(env, "stop"); code != 0 { + t.Fatalf("stop: code=%d out=%s", code, out) + } +} + +func TestFirstInstalledDaemonRunOnboardsAutostart(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + env := envWithoutSystemctl(home) + t.Cleanup(func() { killDaemonIfRunning(home) }) + installed := filepath.Join(home, ".local", "bin", "gitwatchd") + os.MkdirAll(filepath.Dir(installed), 0o755) + binary, err := os.ReadFile(testBinary) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(installed, binary, 0o755); err != nil { + t.Fatal(err) + } + + repo := newTestRepo(t) + if code, out := runBinary(installed, env, "add", "-s", "0", repo.path); code != 0 { + t.Fatalf("add failed: %s", out) + } + if code, out := runBinary(installed, env, "start"); code != 0 { + t.Fatalf("start: code=%d out=%s", code, out) + } + waitFor(t, 15*time.Second, "the recorded autostart wish", func() bool { + return strings.Contains(stateFileIn(home), `"launch-at-login": "on"`) + }) + if log := daemonLogIn(home); !strings.Contains(log, "systemd not found") || + !strings.Contains(log, "gitwatchd start") { + t.Errorf("the log must explain and point at `gitwatchd start`:\n%s", log) + } + if _, err := os.Stat(filepath.Join(home, ".config", "systemd")); err == nil { + t.Error("no unit may be written when systemd is absent") + } + + // The wish is on record now, so a second start has nothing to say. + if code, out := runBinary(installed, env, "stop"); code != 0 { + t.Fatalf("stop: code=%d out=%s", code, out) + } + if code, out := runBinary(installed, env, "start"); code != 0 { + t.Fatalf("second start: code=%d out=%s", code, out) + } + watched := "watching " + filepath.Base(repo.path) + " (" + waitFor(t, 15*time.Second, "the restarted daemon to watch the repo", func() bool { + return strings.Count(daemonLogIn(home), watched) == 2 + }) + if n := strings.Count(daemonLogIn(home), "systemd not found"); n != 1 { + t.Errorf("autostart said it %d times; once is the whole point:\n%s", n, daemonLogIn(home)) + } + if code, out := runBinary(installed, env, "stop"); code != 0 { + t.Fatalf("second stop: code=%d out=%s", code, out) + } +} + +// PATH holds the tools gitwatchd runs but no systemctl, so no-systemd paths run on any host. +func envWithoutSystemctl(home string) []string { + bindir := filepath.Join(home, "bin") + os.MkdirAll(bindir, 0o755) + for _, tool := range []string{"git", "date", "sh", "bash"} { + if p, err := lookPathIn(os.Getenv("PATH"), tool); err == nil { + os.Symlink(p, filepath.Join(bindir, tool)) + } + } + env := isolatedEnv(home) + for i, e := range env { + if strings.HasPrefix(e, "PATH=") { + env[i] = "PATH=" + bindir + } + } + return env +} + +func lookPathIn(path, tool string) (string, error) { + for _, dir := range strings.Split(path, ":") { + candidate := filepath.Join(dir, tool) + if info, err := os.Stat(candidate); err == nil && info.Mode()&0o111 != 0 { + return candidate, nil + } + } + return "", os.ErrNotExist +} + +func daemonPidIn(home string) int { + raw, err := os.ReadFile(filepath.Join(home, "state", "gitwatchd.pid")) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(raw))) + return pid +} + +func daemonLogIn(home string) string { + raw, _ := os.ReadFile(filepath.Join(home, "state", "daemon.log")) + return string(raw) +} + +func stateFileIn(home string) string { + raw, _ := os.ReadFile(filepath.Join(home, "state", "state.json")) + return string(raw) +} + +func killDaemonIfRunning(home string) { + if pid := daemonPidIn(home); pid > 0 { + syscall.Kill(pid, syscall.SIGKILL) + } +} + +func startWatcher(t *testing.T, spec *RepoSpec) *repoWatcher { + t.Helper() + w, err := newRepoWatcher(spec, func(string, *RepoStatus) {}, func(string, ...any) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(w.stopWatching) + return w +} + +func waitFor(t *testing.T, timeout time.Duration, what string, ok func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if ok() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func TestWatcherCommitsAfterTheSettleWindow(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2")) + repo.write("notes.txt", "hello\n") + waitFor(t, 10*time.Second, "the auto-commit", func() bool { return repo.commitCount() == 1 }) +} + +func TestWatcherIgnoresItsOwnGitChurn(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2")) + repo.write("notes.txt", "hello\n") + waitFor(t, 10*time.Second, "the auto-commit", func() bool { return repo.commitCount() == 1 }) + // The commit itself churns .git; a retrigger loop would commit again + // (or spin). Nothing should happen now. + time.Sleep(1 * time.Second) + if repo.commitCount() != 1 { + t.Errorf("commits = %d, the watcher retriggered on .git churn", repo.commitCount()) + } +} + +func TestWatcherHonorsExcludes(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2", "-x", `\.log$`)) + repo.write("debug.log", "noise\n") + time.Sleep(1 * time.Second) + if repo.commitCount() != 0 { + t.Error("an excluded change must not trigger a cycle") + } + // A non-excluded change still commits (and sweeps in the .log file, + // exactly like gitwatch: -x filters events, not git add). + repo.write("notes.txt", "hello\n") + waitFor(t, 10*time.Second, "the auto-commit", func() bool { return repo.commitCount() == 1 }) +} + +func TestWatcherSeesNewSubdirectories(t *testing.T) { + repo := newTestRepo(t) + startWatcher(t, repo.spec("-s", "0.2")) + os.MkdirAll(filepath.Join(repo.path, "fresh", "deep"), 0o755) + time.Sleep(500 * time.Millisecond) // let the new subtree's watches land + repo.write("fresh/deep/inner.txt", "made inside a new directory\n") + waitFor(t, 10*time.Second, "a commit from inside the new subtree", func() bool { + return repo.commitCount() == 1 && pendingCount(repo.path, "") == 0 + }) +} + +func TestWatcherFileTargetSurvivesAtomicSaves(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("b.txt", "not watched\n") + + spec, _ := parseRepoSpec([]string{"-s", "0.2", filepath.Join(repo.path, "a.txt")}) + startWatcher(t, spec) + + // An editor-style atomic save: write a temp file, rename over the target. + tmp := filepath.Join(repo.path, "a.txt.tmp") + os.WriteFile(tmp, []byte("v2\n"), 0o644) + os.Rename(tmp, filepath.Join(repo.path, "a.txt")) + waitFor(t, 10*time.Second, "the file-target commit", func() bool { return repo.commitCount() == 2 }) + if pendingCount(repo.path, "") != 1 { + t.Error("b.txt stays uncommitted, only the file target is committed") + } +} + +func TestWatcherFlushNowCommitsWithoutEvents(t *testing.T) { + repo := newTestRepo(t) + repo.write("pending.txt", "already here\n") + w := startWatcher(t, repo.spec("-s", "0.2")) + w.flushNow() // what -f and a resume catch-up do + waitFor(t, 10*time.Second, "the flush commit", func() bool { return repo.commitCount() == 1 }) +} + +func TestWatchLimitExhaustionSurfacesAsRepoState(t *testing.T) { + repo := newTestRepo(t) + var published *RepoStatus + w, err := newRepoWatcher(repo.spec("-s", "0.2"), + func(_ string, s *RepoStatus) { published = s }, func(string, ...any) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(w.stopWatching) + w.recordWatchFailure("/some/dir", syscall.ENOSPC) + w.publishStatus() + if published == nil || published.ErrorLabel != "watch failing" { + t.Fatalf("got %+v", published) + } + if !strings.Contains(published.Detail, "max_user_watches") { + t.Errorf("the fix should be named: %q", published.Detail) + } +} + +func TestStateFileKeepsSettingsAndRepoStateApart(t *testing.T) { + t.Setenv("GITWATCHD_STATE_DIR", filepath.Join(t.TempDir(), "state")) + setRepoStatus("/repo/one", &RepoStatus{ErrorLabel: "push failing", Attempts: 3}) + setLaunchAtLogin(false) + + s := readState() + if s.LaunchAtLogin != "off" || s.Repos["/repo/one"].Attempts != 3 { + t.Fatalf("recording a setting lost the repo state: %+v", s) + } + setRepoStatus("/repo/one", nil) + if s := readState(); s.LaunchAtLogin != "off" || len(s.Repos) != 0 { + t.Errorf("clearing a repo took the setting with it: %+v", s) + } +} + +// Holding the flock here keeps the binary's `autostart off` waiting until a +// repo status has landed, which its own update then has to preserve. +func TestStateFileWritesAreArbitratedBetweenProcesses(t *testing.T) { + if testBinary == "" { + t.Fatal("test binary did not build") + } + home := t.TempDir() + stateDirectory := filepath.Join(home, "state") + t.Setenv("GITWATCHD_STATE_DIR", stateDirectory) + os.MkdirAll(stateDirectory, 0o755) + + lock, err := os.OpenFile(filepath.Join(stateDirectory, "state.lock"), os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { + t.Fatal(err) + } + + recorded := make(chan struct{}) + go func() { + defer close(recorded) + runCLI(isolatedEnv(home), "autostart", "off") + }() + time.Sleep(500 * time.Millisecond) + if strings.Contains(stateFileIn(home), "launch-at-login") { + t.Fatal("the other writer got in while the lock was held") + } + + // By hand: this test already holds the lock updateState would wait for. + s := readState() + s.Repos = map[string]RepoStatus{"/repo/one": {ErrorLabel: "push failing", Attempts: 1}} + writeState(s) + syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + + <-recorded + final := readState() + if final.LaunchAtLogin != "off" { + t.Errorf("the waiting writer's setting never landed: %+v", final) + } + if _, ok := final.Repos["/repo/one"]; !ok { + t.Errorf("the waiting writer dropped the repo state written meanwhile: %+v", final) + } +} diff --git a/linux/git_driver.go b/linux/git_driver.go new file mode 100644 index 0000000..ce16db7 --- /dev/null +++ b/linux/git_driver.go @@ -0,0 +1,247 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Thin wrapper around git. + +// What one auto-commit cycle (or push retry) accomplished. +type CommitOutcome int + +const ( + Clean CommitOutcome = iota // nothing to commit + SkippedMerge // -M: merge in progress, cycle skipped + Committed // committed; no remote configured + Pushed // committed and pushed + CommitFailed // Detail carries the git error line + RebaseConflict // -R: pull --rebase hit a conflict + PushFailed +) + +type Outcome struct { + Kind CommitOutcome + Detail string +} + +// Menu-row label when this outcome is an error state, "" when healthy. +func (o Outcome) ErrorLabel() string { + switch o.Kind { + case PushFailed: + return "push failing" + case RebaseConflict: + return "rebase conflict" + case CommitFailed: + return "commit failing" + } + return "" +} + +func runCommand(exe string, args []string, cwd string) (int, string) { + cmd := exec.Command(exe, args...) + if cwd != "" { + cmd.Dir = cwd + } + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil { + if exit, ok := err.(*exec.ExitError); ok { + return exit.ExitCode(), text + } + return -1, err.Error() + } + return 0, text +} + +func gitRun(args []string, dir string, gitDir string) (int, string) { + full := args + // -g repos get upstream's exact override on every call: + // `git --work-tree $TARGETDIR --git-dir $GIT_DIR `. + if gitDir != "" { + full = append([]string{"--work-tree", dir, "--git-dir", gitDir}, args...) + } + return runCommand("git", full, dir) +} + +func isRepo(dir string, gitDir string) bool { + _, out := gitRun([]string{"rev-parse", "--is-inside-work-tree"}, dir, gitDir) + return out == "true" +} + +func currentBranch(dir string, gitDir string) string { + code, out := gitRun([]string{"rev-parse", "--abbrev-ref", "HEAD"}, dir, gitDir) + if code != 0 { + return "?" + } + return out +} + +func pendingCount(dir string, gitDir string) int { + _, out := gitRun([]string{"status", "--porcelain"}, dir, gitDir) + if out == "" { + return 0 + } + return len(strings.Split(out, "\n")) +} + +func lastCommitSummary(dir string, gitDir string) string { + code, out := gitRun([]string{"log", "-1", "--pretty=%cr · %s"}, dir, gitDir) + if code != 0 { + return "no commits yet" + } + return out +} + +func gitStateExists(names []string, dir string, gitDir string) bool { + code, top := gitRun([]string{"rev-parse", "--git-dir"}, dir, gitDir) + if code != 0 { + return false + } + gitDirPath := top + if !filepath.IsAbs(gitDirPath) { + gitDirPath = filepath.Join(dir, gitDirPath) + } + for _, name := range names { + if _, err := os.Stat(filepath.Join(gitDirPath, name)); err == nil { + return true + } + } + return false +} + +// gitwatch's is_merging: MERGE_HEAD only (a rebase does not count, upstream). +func hasMergeInProgress(dir string, gitDir string) bool { + return gitStateExists([]string{"MERGE_HEAD"}, dir, gitDir) +} + +func hasRebaseInProgress(dir string, gitDir string) bool { + return gitStateExists([]string{"rebase-merge", "rebase-apply"}, dir, gitDir) +} + +// Stage all, commit (honoring -m/-d/-M), then optionally pull --rebase (-R) +// and push (-r/-b). One gitwatch cycle. +func autoCommit(spec *RepoSpec) Outcome { + dir := spec.WorkDir() + if spec.NoMergeCommit && hasMergeInProgress(dir, spec.GitDir) { + return Outcome{Kind: SkippedMerge} + } + if pendingCount(dir, spec.GitDir) == 0 { + return Outcome{Kind: Clean} + } + + // 1. Build the message before add: upstream's order, and -c/-C (not + // ported yet) must see the unstaged tree. Upstream's ${COMMITMSG/\%d/...} + // splices the date into the first %d only. + msg := strings.Replace(spec.Message, "%d", formattedDate(spec.DateFormat), 1) + + // 2. Stage upstream's GIT_ADD_ARGS: "--all ." scoped to the target + // directory, or just the file for a file target. + addTarget := "." + if spec.IsFileTarget() { + addTarget = spec.Path + } + gitRun([]string{"add", "--all", addTarget}, dir, spec.GitDir) + + // 3. Commit. commitOutcome is reporting only; it never gates step 4. + code, out := gitRun([]string{"commit", "-m", msg}, dir, spec.GitDir) + commitOutcome := Outcome{Kind: Committed} + if code != 0 { + // Repo-wide changes outside the watched subtree stage nothing. + if strings.Contains(out, "nothing to commit") || + strings.Contains(out, "nothing added to commit") || + strings.Contains(out, "no changes added to commit") { + commitOutcome = Outcome{Kind: Clean} + } else { + commitOutcome = Outcome{Kind: CommitFailed, Detail: errorSummary(out)} + } + } + + // 4. Pull (-R) and push regardless of the commit result (gitwatch + // parity), so a repo whose commit a hook blocks still pushes what is + // already committed. Without a remote, skip push() entirely: its + // no-remote return would mask a commit failure. + if spec.Remote == "" { + return commitOutcome + } + pushed := push(spec) + if commitOutcome.Kind == CommitFailed { + return commitOutcome + } + return pushed +} + +func push(spec *RepoSpec) Outcome { + if spec.Remote == "" { + return Outcome{Kind: Committed} + } + dir := spec.WorkDir() + pullFailure := "" + if spec.Rebase { + code, out := gitRun([]string{"pull", "--rebase", spec.Remote}, dir, spec.GitDir) + if code != 0 { + pullFailure = errorSummary(out) + } + } + code, out := gitRun(pushArgs(spec.Remote, spec), dir, spec.GitDir) + + if pullFailure != "" { + // A conflict leaves a rebase in progress and needs the user; + // anything else (offline, auth) is transient and worth retrying. + if hasRebaseInProgress(dir, spec.GitDir) { + return Outcome{Kind: RebaseConflict, Detail: pullFailure} + } + return Outcome{Kind: PushFailed, Detail: pullFailure} + } + if code != 0 { + return Outcome{Kind: PushFailed, Detail: errorSummary(out)} + } + return Outcome{Kind: Pushed} +} + +// gitwatch's push command: without -b, a bare `push ` (git's +// push.default decides); with -b, push `:`, or just +// `` from a detached HEAD. +func pushArgs(remote string, spec *RepoSpec) []string { + if spec.Branch == "" { + return []string{"push", remote} + } + code, head := gitRun([]string{"symbolic-ref", "HEAD"}, spec.WorkDir(), spec.GitDir) + if code != 0 { + return []string{"push", remote, spec.Branch} + } + current := strings.Replace(head, "refs/heads/", "", 1) + return []string{"push", remote, current + ":" + spec.Branch} +} + +func errorSummary(out string) string { + var lines []string + for _, l := range strings.Split(out, "\n") { + l = strings.TrimSpace(l) + if l != "" { + lines = append(lines, l) + } + } + for _, l := range lines { + if strings.HasPrefix(l, "error:") || strings.HasPrefix(l, "fatal:") || + strings.HasPrefix(l, "! [rejected]") || strings.HasPrefix(l, "! [remote rejected]") { + return l + } + } + if len(lines) > 0 { + return lines[len(lines)-1] + } + return "unknown git error" +} + +// The -d value passes to date(1) verbatim (the user includes the leading +), to +// match the upstream gitwatch +func formattedDate(fmt string) string { + code, out := runCommand("date", []string{fmt}, "") + if code != 0 { + return "" + } + return out +} diff --git a/linux/git_driver_test.go b/linux/git_driver_test.go new file mode 100644 index 0000000..b05d988 --- /dev/null +++ b/linux/git_driver_test.go @@ -0,0 +1,512 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +type testRepo struct { + t *testing.T + path string +} + +func newTestRepo(t *testing.T) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "repo")} + os.MkdirAll(r.path, 0o755) + r.git("init", "-q", "-b", "main") + r.configureUser() + return r +} + +// A second working copy of a remote (a colleague's machine). +func newCloneOf(t *testing.T, remote *bareRemote) *testRepo { + t.Helper() + r := &testRepo{t: t, path: filepath.Join(t.TempDir(), "clone")} + runCommand("git", []string{"clone", "-q", remote.path, r.path}, "") + r.configureUser() + return r +} + +func (r *testRepo) configureUser() { + r.git("config", "user.email", "tests@gitwatchd.local") + r.git("config", "user.name", "gitwatchd tests") + r.git("config", "commit.gpgsign", "false") +} + +// The spec `gitwatchd add ` would produce. +func (r *testRepo) spec(flags ...string) *RepoSpec { + r.t.Helper() + spec, errMsg := parseRepoSpec(append(flags, r.path)) + if spec == nil { + r.t.Fatalf("spec did not parse: %s", errMsg) + } + return spec +} + +func (r *testRepo) git(args ...string) string { + _, out := gitRun(args, r.path, "") + return out +} + +func (r *testRepo) write(file, contents string) { + r.t.Helper() + full := filepath.Join(r.path, file) + os.MkdirAll(filepath.Dir(full), 0o755) + if err := os.WriteFile(full, []byte(contents), 0o644); err != nil { + r.t.Fatal(err) + } +} + +func (r *testRepo) commitCount() int { + n, _ := strconv.Atoi(r.git("rev-list", "--count", "HEAD")) + return n +} + +func (r *testRepo) lastMessage() string { return r.git("log", "-1", "--pretty=%s") } + +func (r *testRepo) midMerge() bool { + _, err := os.Stat(filepath.Join(r.path, ".git", "MERGE_HEAD")) + return err == nil +} + +func (r *testRepo) midRebase() bool { + for _, d := range []string{"rebase-merge", "rebase-apply"} { + if _, err := os.Stat(filepath.Join(r.path, ".git", d)); err == nil { + return true + } + } + return false +} + +func (r *testRepo) addOrigin() *bareRemote { + remote := newBareRemote(r.t) + r.git("remote", "add", "origin", remote.path) + return remote +} + +func (r *testRepo) addOriginURL(url string) { + r.git("remote", "add", "origin", url) +} + +func (r *testRepo) setOriginURL(url string) { r.git("remote", "set-url", "origin", url) } + +func (r *testRepo) installFailingPreCommitHook(printing string) { + hook := filepath.Join(r.path, ".git", "hooks", "pre-commit") + os.WriteFile(hook, []byte("#!/bin/sh\necho '"+printing+"'\nexit 1\n"), 0o755) +} + +func (r *testRepo) commit(file, contents, message string) { + r.write(file, contents) + r.git("add", "-A") + r.git("commit", "-q", "-m", message) +} + +// Set main to track origin/main, as a cloned repo would. The branch-less +// `pull --rebase ` needs it. +func (r *testRepo) trackOrigin() { + r.git("fetch", "-q", "origin") + r.git("branch", "-q", "--set-upstream-to=origin/main", "main") +} + +// Stop this repo mid-merge on a real conflict. Plain git only, so tests +// never exercise the engine during their own setup. +func (r *testRepo) conflictedMerge() { + r.commit("f.txt", "base\n", "base") + r.git("checkout", "-q", "-b", "side") + r.commit("f.txt", "side\n", "side edit") + r.git("checkout", "-q", "main") + r.commit("f.txt", "main\n", "main edit") + r.git("merge", "side") // conflicts, leaving MERGE_HEAD behind +} + +type bareRemote struct { + path string +} + +func newBareRemote(t *testing.T) *bareRemote { + t.Helper() + b := &bareRemote{path: filepath.Join(t.TempDir(), "remote.git")} + runCommand("git", []string{"init", "-q", "--bare", "-b", "main", b.path}, "") + return b +} + +func (b *bareRemote) commitCount() int { + code, out := gitRun([]string{"rev-list", "--count", "main"}, b.path, "") + if code != 0 { + return 0 + } + n, _ := strconv.Atoi(out) + return n +} + +func (b *bareRemote) lastMessage() string { + _, out := gitRun([]string{"log", "-1", "--pretty=%s", "main"}, b.path, "") + return out +} + +// Engine tests drive autoCommit / push against real throwaway git repos, +// asserting both the reported outcome and the repo state left behind +// (the gitwatch parity contract: same commands, same end state). + +func TestCleanRepoDoesNothing(t *testing.T) { + repo := newTestRepo(t) + repo.write("seed.txt", "v1") + autoCommit(repo.spec()) // absorb the initial commit + if got := autoCommit(repo.spec()); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Error("no extra commit should appear") + } +} + +func TestChangesCommitLocallyWithoutRemote(t *testing.T) { + repo := newTestRepo(t) + repo.write("notes.txt", "hello") + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.commitCount() != 1 { + t.Errorf("commits = %d", repo.commitCount()) + } + if !strings.HasPrefix(repo.lastMessage(), "gitwatchd auto-commit") { + t.Errorf("default message expected, got: %s", repo.lastMessage()) + } +} + +func TestCustomMessageAndDateExpansion(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved on %d", "-d", "+%Y")) + if !strings.HasPrefix(repo.lastMessage(), "saved on 2") { // "saved on 2026" + t.Errorf("got: %s", repo.lastMessage()) + } +} + +// Sharp corner, kept for upstream parity: -d goes to date(1) raw, so a +// format without a leading + splices an empty date. Git's commit-message +// cleanup then trims the trailing whitespace. +func TestSharpCornerRawDateFormatWithoutPlusSplicesEmptyDate(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "at %d", "-d", "%Y")) + if repo.lastMessage() != "at" { + t.Errorf("got %q, want %q", repo.lastMessage(), "at") + } +} + +func TestOnlyTheFirstDateTokenIsExpanded(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec("-m", "saved %d then %d", "-d", "+%Y")) + got := repo.lastMessage() + if !strings.HasPrefix(got, "saved 2") || !strings.HasSuffix(got, " then %d") { + t.Errorf("upstream splices the date into the first %%d only, got: %s", got) + } +} + +func TestPushToRemote(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the remote should have the commit") + } +} + +func TestUnreachableRemoteReportsPushFailedButCommitSurvives(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "not-a-remote.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if got.Detail == "" { + t.Error("the git error is captured for status") + } + if repo.commitCount() != 1 { + t.Error("gitwatch parity: commit stays, only the push failed") + } +} + +func TestPushRetriesStrandedCommitAfterOutage(t *testing.T) { + repo := newTestRepo(t) + origin := newBareRemote(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "offline.git")) // remote "down" + repo.write("a.txt", "1") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main")); got.Kind != PushFailed { + t.Fatalf("setup: expected the first push to fail, got %+v", got) + } + repo.setOriginURL(origin.path) // remote "back up" + if got := push(repo.spec("-r", "origin", "-b", "main")); got.Kind != Pushed { + t.Errorf("got %+v", got) + } + if origin.commitCount() != 1 { + t.Error("the earlier commit reached the remote") + } +} + +func TestPullFailureWhileOfflineIsTransientNotConflict(t *testing.T) { + repo := newTestRepo(t) + repo.addOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("a.txt", "1") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != PushFailed { + t.Fatalf("expected pushFailed, got %+v", got) + } + if repo.midRebase() { + t.Error("no rebase was ever started, so the retry loop may heal this") + } +} + +func TestIdempotentRetryStillReportsPushed(t *testing.T) { + repo := newTestRepo(t) + repo.addOrigin() + repo.write("a.txt", "1") + spec := repo.spec("-r", "origin", "-b", "main") + autoCommit(spec) + if got := push(spec); got.Kind != Pushed { // "Everything up-to-date" + t.Errorf("got %+v", got) + } +} + +func TestPushFormWithoutBranch(t *testing.T) { + spec, _ := parseRepoSpec([]string{"-r", "origin", "/tmp/x"}) + got := pushArgs("origin", spec) + if strings.Join(got, " ") != "push origin" { + t.Errorf("got %v", got) + } +} + +func TestPushFormWithBranch(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "-b", "feature") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin feature:main" { + t.Errorf("got %v", got) + } +} + +func TestPushFormFromDetachedHead(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "1") + autoCommit(repo.spec()) + repo.git("checkout", "-q", "--detach") + got := pushArgs("origin", repo.spec("-r", "origin", "-b", "main")) + if strings.Join(got, " ") != "push origin main" { + t.Errorf("got %v", got) + } +} + +func TestRefspecEndToEnd(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("a.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.git("checkout", "-q", "-b", "feature") + repo.write("b.txt", "on feature\n") + if got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-m", "from feature")); got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 2 { + t.Error("the remote's main received the feature commit") + } + if origin.lastMessage() != "from feature" { + t.Errorf("got %q", origin.lastMessage()) + } +} + +func TestRebaseThenPush(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("ours.txt", "base\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) + colleague.write("theirs.txt", "from the other machine\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main", "-m", "made elsewhere")) + + repo.write("ours.txt", "updated here\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R", "-m", "made here")) + if got.Kind != Pushed { + t.Fatalf("got %+v", got) + } + if origin.commitCount() != 3 { + t.Error("base, theirs, ours: one linear history") + } + if origin.lastMessage() != "made here" { + t.Error("our commit was rebased on top") + } + if !strings.Contains(repo.git("log", "--pretty=%s"), "made elsewhere") { + t.Error("their commit is now part of our local history") + } +} + +func TestMergeGuardSkipsMidMerge(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if !repo.midMerge() { + t.Fatal("setup: repo should be mid-merge") + } + if got := autoCommit(repo.spec("-M")); got.Kind != SkippedMerge { + t.Errorf("got %+v", got) + } + if !repo.midMerge() { + t.Error("the merge is left exactly as it was") + } +} + +func TestWithoutMergeGuardTheMergeIsCommitted(t *testing.T) { + repo := newTestRepo(t) + repo.conflictedMerge() + if got := autoCommit(repo.spec()); got.Kind != Committed { + t.Errorf("got %+v", got) + } + if repo.midMerge() { + t.Error("the commit concluded the merge, as gitwatch would") + } +} + +func TestSubdirectoryTargetCommitsOnlyTheSubtree(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("outer.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "sub/inner.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestFileTargetCommitsOnlyThatFile(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "a.txt")}) + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if pendingCount(repo.path, "") != 1 { + t.Error("b.txt stays uncommitted") + } + if repo.git("show", "--name-only", "--pretty=") != "a.txt" { + t.Errorf("got %q", repo.git("show", "--name-only", "--pretty=")) + } +} + +func TestOutsideChangesOnlyReportClean(t *testing.T) { + repo := newTestRepo(t) + repo.write("sub/inner.txt", "v1\n") + autoCommit(repo.spec()) + repo.write("outer.txt", "elsewhere\n") + before := repo.commitCount() + spec, _ := parseRepoSpec([]string{filepath.Join(repo.path, "sub")}) + if got := autoCommit(spec); got.Kind != Clean { + t.Errorf("got %+v", got) + } + if repo.commitCount() != before { + t.Error("no commit should appear") + } +} + +func TestDetachedGitDir(t *testing.T) { + repo := newTestRepo(t) + repo.write("a.txt", "base\n") + autoCommit(repo.spec()) + gitDir := filepath.Join(t.TempDir(), "elsewhere.git") + if err := os.Rename(filepath.Join(repo.path, ".git"), gitDir); err != nil { + t.Fatal(err) + } + spec := repo.spec("-g", gitDir) + if !isRepo(repo.path, gitDir) { + t.Error("the daemon/CLI validation path must accept a -g repo") + } + repo.write("a.txt", "changed\n") + if got := autoCommit(spec); got.Kind != Committed { + t.Fatalf("got %+v", got) + } + if _, out := gitRun([]string{"rev-list", "--count", "HEAD"}, repo.path, gitDir); out != "2" { + t.Errorf("commits = %s", out) + } + if got := autoCommit(spec); got.Kind != Clean { + t.Error("and the change was fully committed") + } +} + +func TestFailingPreCommitHookReportsCommitFailed(t *testing.T) { + repo := newTestRepo(t) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "1") + got := autoCommit(repo.spec()) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } +} + +// The push still runs after a failed commit (gitwatch parity), but its result +// must not mask the commit failure: the menu/status row has to say "commit +// failing", not "pushed". +func TestFailingCommitIsStillReportedWhenThePushSucceeds(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("seed.txt", "1") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.installFailingPreCommitHook("lint says no") + repo.write("a.txt", "2") + got := autoCommit(repo.spec("-r", "origin", "-b", "main")) + if got.Kind != CommitFailed { + t.Fatalf("expected commitFailed, got %+v", got) + } + if !strings.Contains(got.Detail, "lint says no") { + t.Errorf("hook output surfaces: got %q", got.Detail) + } + if origin.commitCount() != 1 || repo.commitCount() != 1 { + t.Error("the blocked commit never happened, and the push had nothing new to send") + } +} + +func TestRebaseConflictIsReportedAndLeftInProgress(t *testing.T) { + repo := newTestRepo(t) + origin := repo.addOrigin() + repo.write("shared.txt", "original\n") + autoCommit(repo.spec("-r", "origin", "-b", "main")) + repo.trackOrigin() + + colleague := newCloneOf(t, origin) // someone else pushes first + colleague.write("shared.txt", "colleague's version\n") + autoCommit(colleague.spec("-r", "origin", "-b", "main")) + + repo.write("shared.txt", "our conflicting version\n") + got := autoCommit(repo.spec("-r", "origin", "-b", "main", "-R")) + if got.Kind != RebaseConflict { + t.Fatalf("expected rebaseConflict, got %+v", got) + } + // gitwatch parity: no abort. The conflicted rebase is left in progress + // for the user to resolve; we only make it visible in status. + if !repo.midRebase() { + t.Error("the conflicted rebase is left in progress") + } +} diff --git a/linux/gitwatch_parity_test.go b/linux/gitwatch_parity_test.go new file mode 100644 index 0000000..d7692bb --- /dev/null +++ b/linux/gitwatch_parity_test.go @@ -0,0 +1,316 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Differential parity tests: the same scenario runs through upstream +// gitwatch.sh (the model) and through our engine, each on its own +// identically-built world, and the observable git state afterwards must be +// identical. Scenario setup uses only plain git commands, so neither +// implementation touches the world until the measured cycle. +// +// Determinism: gitwatch's -f performs one commit cycle before entering its +// watch loop, and GW_INW_BIN lets us point the watcher at a stub that exits +// immediately, so the script does exactly one cycle and terminates. Every +// scenario passes an explicit -m without %d, since default messages and +// timestamps intentionally differ. + +// Everything a user could observe about a world after one cycle. +type repoState struct { + commitCount int + lastMessage string + pendingChanges int + branch string + midMerge bool + midRebase bool + remoteCommitCount int // -1 when the scenario has no remote + remoteLastMessage string +} + +func (s repoState) String() string { + return fmt.Sprintf("commits=%d last=%q pending=%d branch=%s midMerge=%v midRebase=%v remote(commits=%d last=%q)", + s.commitCount, s.lastMessage, s.pendingChanges, s.branch, + s.midMerge, s.midRebase, s.remoteCommitCount, s.remoteLastMessage) +} + +func stateOf(repo *testRepo, remote *bareRemote) repoState { + s := repoState{ + commitCount: repo.commitCount(), + lastMessage: repo.lastMessage(), + pendingChanges: pendingCount(repo.path, ""), + branch: currentBranch(repo.path, ""), + midMerge: repo.midMerge(), + midRebase: repo.midRebase(), + remoteCommitCount: -1, + } + if remote != nil { + s.remoteCommitCount = remote.commitCount() + s.remoteLastMessage = remote.lastMessage() + } + return s +} + +// The upstream script, vendored once for the whole repo in oracle/; both +// implementations measure themselves against that same copy. +func gitwatchScript(t *testing.T) string { + t.Helper() + wd, _ := os.Getwd() + script := filepath.Join(wd, "..", "oracle", "gitwatch.sh") + if _, err := os.Stat(script); err != nil { + t.Fatalf("vendored gitwatch.sh not found at %s", script) + } + return script +} + +// A watcher stub that exits immediately: gitwatch runs its -f startup +// commit, the watch pipe hits EOF, and the script terminates. +func stubWatcher(t *testing.T) string { + t.Helper() + stub := filepath.Join(t.TempDir(), "inotifywait") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + return stub +} + +// Run upstream gitwatch for exactly one commit cycle on `target`. +func runOneCycle(t *testing.T, flags []string, target string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + args := append([]string{gitwatchScript(t), "-f"}, flags...) + args = append(args, target) + cmd := exec.CommandContext(ctx, "bash", args...) + cmd.Env = append(os.Environ(), "GW_INW_BIN="+stubWatcher(t)) + cmd.Run() // fire-and-forget, like gitwatch itself +} + +// Build two identical worlds, run the model on one and our engine on the +// other with the same flags, and return both fingerprints. +func twins(t *testing.T, flags []string, withRemote bool, targetSuffix string, + setup func(*testRepo, *bareRemote)) (model, ours repoState) { + t.Helper() + target := func(repo *testRepo) string { + if targetSuffix != "" { + return filepath.Join(repo.path, targetSuffix) + } + return repo.path + } + + a := newTestRepo(t) + var ra *bareRemote + if withRemote { + ra = a.addOrigin() + } + setup(a, ra) + runOneCycle(t, flags, target(a)) + model = stateOf(a, ra) + + b := newTestRepo(t) + var rb *bareRemote + if withRemote { + rb = b.addOrigin() + } + setup(b, rb) + spec, errMsg := parseRepoSpec(append(flags, target(b))) + if spec == nil { + t.Fatal(errMsg) + } + autoCommit(spec) + ours = stateOf(b, rb) + return model, ours +} + +func expectParity(t *testing.T, model, ours repoState) { + t.Helper() + if model != ours { + t.Errorf("state diverged\n gitwatch: %v\n ours: %v", model, ours) + } +} + +// Plain-git scenario builders (no engine involvement). +func seed(repo *testRepo) { + repo.write("seed.txt", "seed\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") +} + +func seedAndPush(repo *testRepo) { + seed(repo) + repo.git("push", "-q", "origin", "main") + repo.trackOrigin() +} + +func colleaguePushes(t *testing.T, remote *bareRemote, file, message string) { + colleague := newCloneOf(t, remote) + colleague.write(file, "from the other machine\n") + colleague.git("add", "-A") + colleague.git("commit", "-q", "-m", message) + colleague.git("push", "-q", "origin", "main") +} + +func TestParityCleanRepo(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + }) + expectParity(t, model, ours) + if ours.commitCount != 1 { + t.Errorf("neither side commits anything: %v", ours) + } +} + +func TestParityPlainCommit(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.lastMessage != "cycle" || ours.pendingChanges != 0 { + t.Errorf("same commit, same message, clean tree afterwards: %v", ours) + } +} + +// Sharp corner, kept for upstream parity: -d passes to date(1) raw and only +// the first %d is spliced. The year-only format keeps the spliced date +// identical across the two runs, so the fingerprints compare exactly. +func TestParitySharpCornerRawDateFormatAndFirstTokenOnly(t *testing.T) { + model, ours := twins(t, []string{"-m", "at %d then %d", "-d", "+%Y"}, false, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if !strings.HasPrefix(ours.lastMessage, "at 2") || !strings.HasSuffix(ours.lastMessage, " then %d") { + t.Errorf("got %q", ours.lastMessage) + } +} + +func TestParityPushToRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seedAndPush(repo) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 2 || ours.remoteLastMessage != "cycle" { + t.Errorf("both push the commit: %v", ours) + } +} + +func TestParityUnreachableRemote(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) + repo.setOriginURL(filepath.Join(t.TempDir(), "gone.git")) + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 { + t.Error("fire-and-forget: the commit still happens") + } +} + +// Upstream runs the pull and the push after `git commit` whether or not the +// commit succeeded, so a repo whose commit a hook blocks still pushes what is +// already committed. The seed here is deliberately left unpushed, which is what +// lets the two worlds disagree if we ever short-circuit on a failed commit. +func TestParityFailedCommitStillPushes(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main"}, true, "", + func(repo *testRepo, _ *bareRemote) { + seed(repo) // committed locally, never pushed + repo.installFailingPreCommitHook("lint says no") + repo.write("notes.txt", "hello\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 1 { + t.Errorf("the already-committed seed reaches the remote on both sides: %v", ours) + } + if ours.commitCount != 1 || ours.pendingChanges == 0 { + t.Errorf("the hook blocked the new commit on both sides: %v", ours) + } +} + +func TestParityMergeGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-M"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if !ours.midMerge { + t.Error("the merge is left untouched on both sides") + } +} + +func TestParityMergeCommittedWithoutGuard(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "", func(repo *testRepo, _ *bareRemote) { + repo.conflictedMerge() + }) + expectParity(t, model, ours) + if ours.midMerge { + t.Error("the cycle concluded the merge on both sides") + } +} + +func TestParityRebaseHappyPath(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "theirs.txt", "made elsewhere") + repo.write("ours.txt", "made here\n") + }) + expectParity(t, model, ours) + if ours.remoteCommitCount != 3 { + t.Error("seed, theirs, ours: one linear history") + } + if ours.remoteLastMessage != "cycle" { + t.Errorf("got %q", ours.remoteLastMessage) + } +} + +func TestParitySubdirectoryTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "sub", func(repo *testRepo, _ *bareRemote) { + repo.write("sub/inner.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("sub/inner.txt", "v2\n") + repo.write("outer.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("outer.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityFileTarget(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle"}, false, "a.txt", func(repo *testRepo, _ *bareRemote) { + repo.write("a.txt", "v1\n") + repo.git("add", "-A") + repo.git("commit", "-q", "-m", "seed") + repo.write("a.txt", "v2\n") + repo.write("b.txt", "left alone\n") + }) + expectParity(t, model, ours) + if ours.commitCount != 2 || ours.pendingChanges != 1 { + t.Errorf("b.txt stays uncommitted on both sides: %v", ours) + } +} + +func TestParityRebaseConflictLeftInProgress(t *testing.T) { + model, ours := twins(t, []string{"-m", "cycle", "-r", "origin", "-b", "main", "-R"}, true, "", + func(repo *testRepo, remote *bareRemote) { + seedAndPush(repo) + colleaguePushes(t, remote, "seed.txt", "conflicting edit") + repo.write("seed.txt", "our conflicting edit\n") + }) + expectParity(t, model, ours) + if !ours.midRebase { + t.Error("both sides stop mid-rebase; -M is the only guard") + } +} diff --git a/linux/main.go b/linux/main.go deleted file mode 100644 index 0d89a27..0000000 --- a/linux/main.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -func main() { - fmt.Fprintln(os.Stderr, "gitwatchd: not implemented on Linux yet") - os.Exit(1) -} diff --git a/linux/utils.go b/linux/utils.go new file mode 100644 index 0000000..cc0c364 --- /dev/null +++ b/linux/utils.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "math" +) + +// Pure formatting helpers, with no gitwatchd vocabulary of their own. + +func truncated(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-1]) + "…" +} + +func ago(seconds float64) string { + if seconds < 5 { + return "just now" + } + return span(seconds) + " ago" +} + +func span(seconds float64) string { + s := int(math.Round(seconds)) + if s < 1 { + s = 1 + } + if s < 90 { + return fmt.Sprintf("%ds", s) + } + if s < 90*60 { + return fmt.Sprintf("%dm", int(math.Round(float64(s)/60))) + } + if s < 36*3600 { + return fmt.Sprintf("%dh", int(math.Round(float64(s)/3600))) + } + return fmt.Sprintf("%dd", int(math.Round(float64(s)/86400))) +} diff --git a/macos/Tests/ParityTests.swift b/macos/Tests/ParityTests.swift index b0016ac..8e8f6ad 100644 --- a/macos/Tests/ParityTests.swift +++ b/macos/Tests/ParityTests.swift @@ -49,7 +49,7 @@ enum GitwatchReference { /// tests work regardless of the working directory. static let script = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() - .appendingPathComponent("Reference/gitwatch.sh").path + .appendingPathComponent("../../oracle/gitwatch.sh").path /// A watcher stub that exits immediately: gitwatch runs its -f startup /// commit, the watch pipe hits EOF, and the script terminates. diff --git a/macos/Tests/Reference/README.md b/oracle/README.md similarity index 70% rename from macos/Tests/Reference/README.md rename to oracle/README.md index 5686691..c6c1e13 100644 --- a/macos/Tests/Reference/README.md +++ b/oracle/README.md @@ -1,9 +1,8 @@ # Upstream reference copy `gitwatch.sh` is a verbatim copy of upstream gitwatch, used as the model in the -differential parity tests (`Tests/ParityTests.swift`): the same scenario runs -through this script and through our engine, and the resulting git state must -be identical. +differential parity tests of both implementations +(`macos/Tests/ParityTests.swift` and `linux/gitwatch_parity_test.go`). - Source: https://raw.githubusercontent.com/gitwatch/gitwatch/master/gitwatch.sh - Fetched: 2026-07-18 diff --git a/macos/Tests/Reference/gitwatch.sh b/oracle/gitwatch.sh similarity index 100% rename from macos/Tests/Reference/gitwatch.sh rename to oracle/gitwatch.sh