8.8 KiB
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 inpackage.yaml) - Cabal file:
converge.cabalis generated frompackage.yamlvia 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(anMVar ()for serialized logging) - Config: YAML config file parsing via
Data.Yaml, XDG path discovery - File watching:
fsnotifyviawatchRepo— one per configured repo, each in its own thread (viaasync) - Debounce: An
MVarpattern coalesces rapid events into one sync per quiet period - Sync cycle (
syncRepo): guarded byisInMiddleOfOperation, then stage + commit + pull + conflict check - Git operations: shell out to
gitviaSystem.Process.readProcessWithExitCode— no libgit2 dependency - Notifications: shell out to
notify-sendviaSystem.Process.spawnProcess - Relay listener:
connectRelayconnects to a converge-relay SSE endpoint, parses push events, and triggers pulls on matching repos (bygitea_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 verificationGET /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)
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)
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, runsgit 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 asorigin, and pushes the initial commit.runGitIn :: FilePath -> [String] -> IO (ExitCode, Text, Text)— runsgit -C <path> <args>.rawGit :: [String] -> IO (ExitCode, Text, Text)— runsgit <args>without-C(forgit clone, etc.).testLogger :: IO Logger— creates a test Logger backed by a freshMVar.- Sentinel helpers (
createMergeHead,createRebaseApply, etc.) — create the known sentinel files/dirs inside.git/.
Writing new tests
- Create a new spec module in
test/(e.g.,test/MyNewSpec.hs) - Export a top-level
spec :: Specvalue - Import it in
test/Spec.hsand add it to thehspecblock - Use
withTempGitRepofor tests that need a git repo - 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(viaOverloadedStrings), notString - Logging goes through the
Loggernewtype wrappingMVar ()— all logging is serialized to stderr - Hostname for commit messages is obtained via
readProcess "hostname" [] "" - Git commands always use
readProcessWithExitCode(notreadProcessexcept 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
gitmust be on PATH (no libgit2 dependency)notify-sendfor desktop notifications (optional — only called on conflict/pull)hostnamecommand for commit message generation- For realtime pull: a running
converge-relayinstance reachable via HTTP(S)