# AGENTS.md — Project guide for AI coding agents ## Project overview **Converge** watches git repositories for file changes, automatically commits them, and pulls from a remote via rebase. It notifies the user via `notify-send` on merge conflicts. - **Language**: Haskell (GHC 9.10.3, Stackage lts-24.38) - **Build system**: Stack (config in `stack.yaml`, packages in `package.yaml`) - **Cabal file**: `converge.cabal` is generated from `package.yaml` via hpack ## File layout ``` src/Converge.hs # Single library module — all core logic app/Main.hs # CLI entry point (optparse-applicative) test/Spec.hs # Test runner (imports and runs all spec modules) test/TestHelper.hs # Shared test utilities (temp repos, sentinel files) test/GitCommitSpec.hs # Commit behaviour tests test/GitPullSpec.hs # Pull/rebase tests test/GitSafetySpec.hs # Operation-in-progress detection tests SPEC.md # Human-readable specifications converge.example.yaml # Example config file converge.service # systemd user service (daemon mode) ``` ## Architecture All core logic lives in the single `Converge` module (`src/Converge.hs`). Key sections: - **Types**: `Options`, `RepoConfig`, `Config`, `Logger` (an `MVar ()` for serialized logging) - **Config**: YAML config file parsing via `Data.Yaml`, XDG path discovery - **File watching**: `fsnotify` via `watchRepo` — one per configured repo, each in its own thread (via `async`) - **Debounce**: An `MVar` pattern coalesces rapid events into one sync per quiet period - **Sync cycle** (`syncRepo`): guarded by `isInMiddleOfOperation`, then stage + commit + pull + conflict check - **Git operations**: shell out to `git` via `System.Process.readProcessWithExitCode` — no libgit2 dependency - **Notifications**: shell out to `notify-send` via `System.Process.spawnProcess` - **Relay listener**: `connectRelay` connects to a converge-relay SSE endpoint, parses push events, and triggers pulls on matching repos (by `gitea_repo` + branch). Runs as a background thread with exponential backoff reconnection. ### Relay architecture ``` ┌─────────────┐ POST /webhook ┌──────────────────┐ GET /events (SSE) ┌──────────────────┐ │ Gitea │ ────────────────> │ converge-relay │ ───────────────────> │ converge (local) │ │ Server │ (push event) │ (Go, hosted) │ event: push │ (Haskell) │ └─────────────┘ └──────────────────┘ └─────────┬─────────┘ │ matches gitea_repo → git pull ``` The Go relay is a zero-dependency pub/sub server. Key design: - `POST /webhook` — receives Gitea push events, optional HMAC-SHA256 verification - `GET /events` — SSE stream, clients connect and hold the connection open - Channel-based broker: `Subscribe()` → channel, `Unsubscribe(ch)`, `Publish(event)` — non-blocking send to all subscribers - Slow subscribers are silently dropped (channel buffer = 16) ### Git operations | Function | Git command | |---|---| | `gitCommitAll` | `git add -A` + `git commit -m "converge: auto-commit from "` | | `gitPull` | `git pull --rebase ` | | `hasConflicts` | `git diff --check` looking for `<<<<<<<` / `=======` / `>>>>>>>` | | `isInMiddleOfOperation` | `git rev-parse --git-dir` then checks sentinel files/dirs | ### Operation detection sentinels `checkIndicatorFiles` checks these inside `.git/`: | File/Dir | Operation | |---|---| | `MERGE_HEAD` | Merge in progress | | `REVERT_HEAD` | Revert in progress | | `CHERRY_PICK_HEAD` | Cherry-pick in progress | | `BISECT_LOG` | Bisect in progress | | `rebase-apply/` | Rebase in progress | | `rebase-merge/` | Rebase in progress | | `sequencer/` | Sequencer-based operation in progress | `REBASE_HEAD` is intentionally excluded — git does not remove it when a rebase completes, causing false positives. ## Build / test / run ### Haskell (converge) ```bash stack build # Build library + executable + tests stack test # Build and run the test suite stack exec converge -- [options] # Run the tool directly ./scripts/build # Format + build + compress (uses Docker via ./hs) ./scripts/test # Run tests (uses Docker via ./hs) ./scripts/run # Run the tool (uses Docker via ./hs) ``` ### Go (converge-relay) ```bash cd converge-relay ./scripts/build # Format + test + build (uses Docker via ./goctl) ./scripts/test # Run tests (uses Docker via ./goctl) ./scripts/run --listen :8080 # Run the relay (uses Docker via ./goctl) ``` ## Test conventions Tests use **hspec** and create real temporary git repositories — no mocking of git commands. This avoids complexity and tests actual git semantics. ### Helper module (`test/TestHelper.hs`) - `withTempGitRepo :: (FilePath -> IO a) -> IO a` — creates a temp dir, runs `git init -b main`, configures user, makes an initial commit, runs the action, cleans up. - `withTempGitRepoWithRemote :: (FilePath -> IO a) -> IO a` — like above but also creates a bare remote, adds it as `origin`, and pushes the initial commit. - `runGitIn :: FilePath -> [String] -> IO (ExitCode, Text, Text)` — runs `git -C `. - `rawGit :: [String] -> IO (ExitCode, Text, Text)` — runs `git ` without `-C` (for `git clone`, etc.). - `testLogger :: IO Logger` — creates a test Logger backed by a fresh `MVar`. - Sentinel helpers (`createMergeHead`, `createRebaseApply`, etc.) — create the known sentinel files/dirs inside `.git/`. ### Writing new tests 1. Create a new spec module in `test/` (e.g., `test/MyNewSpec.hs`) 2. Export a top-level `spec :: Spec` value 3. Import it in `test/Spec.hs` and add it to the `hspec` block 4. Use `withTempGitRepo` for tests that need a git repo 5. If the test needs new exports from `Converge`, add them to the module's export list ### Spec coverage Tests are organized to match `SPEC.md`: Anytime new behavior is added the `SPEC.md` should be updated and a corresponding hspec test written. | Spec | Test module | Key tests | |---|---|---| | Creates commit on file change | `GitCommitSpec` | Commit creation, content inclusion, no duplicate commits | | One commit per debounce window | `GitCommitSpec` | Idempotency of `gitCommitAll` | | Pulls from remote origin | `GitPullSpec` | Fast-forward pull scenario | | Rebases on pull | `GitPullSpec` | Divergent history → linear history, no merge commits | | No-op during operations | `GitSafetySpec` | All 7 sentinel types + clean repo | | `RelaySpec` | SSE line parsing, event folding, JSON parsing, repo matching, operation-in-progress skip | ## Key conventions - **Default extensions** (from `package.yaml`): `OverloadedStrings`, `RecordWildCards`, `ScopedTypeVariables`, `TupleSections` - **GHC options**: `-Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints` - **Strings are `Text`** (via `OverloadedStrings`), not `String` - **Logging** goes through the `Logger` newtype wrapping `MVar ()` — all logging is serialized to stderr - **Hostname** for commit messages is obtained via `readProcess "hostname" [] ""` - **Git commands** always use `readProcessWithExitCode` (not `readProcess` except for hostname) ## Dependencies ### Haskell | Package | Purpose | |---|---| | `async` | Concurrent repo watching | | `fsnotify` | File system event monitoring | | `optparse-applicative` | CLI argument parsing | | `yaml` | Config file parsing | | `http-client` | HTTP client for SSE relay connection | | `http-client-tls` | TLS support for HTTPS relay endpoints | | `http-types` | HTTP status codes | | `aeson` | JSON parsing (push events from relay) | | `bytestring` | Efficient binary data for SSE stream parsing | | `temporary` (test only) | Temp directories for test repos | | `hspec` (test only) | Test framework | ### Go (converge-relay) Zero external dependencies — stdlib only (`net/http`, `log/slog`, `crypto/hmac`, `encoding/json`, `sync`). ## External requirements - `git` must be on PATH (no libgit2 dependency) - `notify-send` for desktop notifications (optional — only called on conflict/pull) - `hostname` command for commit message generation - For realtime pull: a running `converge-relay` instance reachable via HTTP(S)