662854a977
- New test module test/RelaySpec.hs (20 tests):
- extractLines: line splitting, CRLF, partial lines, empty input
- foldLines: single/two push events, non-push ignored, comments, no-space colon,
partial state carry-over across batches
- parseEvent: valid push, non-push, malformed JSON, multi-line data
- handleSSEEvent: pull on matching repo, ignore wrong gitea_repo/branch,
skip pull when merge/rebase/operation in progress
- Exported PushEvent(..), extractLines, foldLines, parseEvent, handleSSEEvent
- Fixed data line order (append instead of prepend+reverse) and event ordering
- Updated AGENTS.md: relay architecture, Go component, new deps, file layout
177 lines
8.8 KiB
Markdown
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 unit file
|
|
```
|
|
|
|
## 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)
|