Files
2026-07-07 21:26:03 -04:00

177 lines
8.8 KiB
Markdown

# 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 <host>"` |
| `gitPull` | `git pull --rebase <remote> <branch>` |
| `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 <path> <args>`.
- `rawGit :: [String] -> IO (ExitCode, Text, Text)` — runs `git <args>` 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)