Compare commits

...

32 Commits

Author SHA1 Message Date
jbrechtel 26df70f9f5 Remove converge-wake service and system-sleep hook
Build / build (push) Successful in 17m6s
2026-07-07 21:26:03 -04:00
jbrechtel 96e66be50e Bumps version
Build / build (push) Successful in 16m12s
2026-06-06 16:11:11 -04:00
jbrechtel c0b566fc70 Don't block on starting the service after sleep
Build / build (push) Has been cancelled
2026-06-06 16:09:04 -04:00
jbrechtel bdcb3d1d20 Bump version to 0.1.0.1
Build / build (push) Successful in 11m29s
2026-05-29 14:10:58 -04:00
jbrechtel cc2df21796 Fix Arch package: remove subdirectory path from source=() array
Build / build (push) Successful in 11m34s
makepkg cannot resolve 'src/system-sleep/converge-wake' in source=() because
it looks for just the basename. The file is still installed correctly by the
package() function which accesses it directly via cd $startdir + relative path.
2026-05-29 13:53:35 -04:00
jbrechtel f546ee450e Add converge-wake.service and systemd-sleep hook for wake-from-sleep sync
Build / build (push) Failing after 12m9s
Creates a oneshot user service (converge-wake.service) that runs
converge --sync after the network is online, and a systemd-sleep hook
(/usr/lib/systemd/system-sleep/converge-wake) that triggers the service
for each logged-in user on resume.

Uses After=network-online.target for proper network timing on
NetworkManager/systemd-networkd setups, with a 5-second ExecStartPre
delay as a safety net for netctl and other environments where
network-online.target may not gate on resume.

Updated PKGBUILD and AGENTS.md accordingly.
2026-05-23 22:38:55 -04:00
jbrechtel 6d165a3912 converge: auto-commit at 2026-05-15 09:14:48 from zendo.brechtel
Build / build (push) Successful in 1m25s
2026-05-15 09:14:48 -04:00
jbrechtel 3edb5c33ee converge: auto-commit at 2026-05-14 23:36:30 from zendo.brechtel
Build / build (push) Failing after 1m8s
2026-05-14 23:36:30 -04:00
jbrechtel ab87e943f3 converge: auto-commit at 2026-05-14 23:26:25 from zendo.brechtel
Build / build (push) Failing after 1m21s
2026-05-14 23:26:25 -04:00
jbrechtel 39f85a3900 converge: auto-commit at 2026-05-14 23:19:34 from zendo.brechtel
Build / build (push) Failing after 57s
2026-05-14 23:19:34 -04:00
jbrechtel af0f03758a converge: auto-commit at 2026-05-14 23:12:04 from zendo.brechtel
Build / build (push) Failing after 1m23s
2026-05-14 23:12:04 -04:00
jbrechtel dee7c95542 converge: auto-commit at 2026-05-14 23:07:12 from zendo.brechtel
Build / build (push) Failing after 1m8s
2026-05-14 23:07:12 -04:00
jbrechtel c7a3690129 converge: auto-commit at 2026-05-14 22:58:49 from zendo.brechtel
Build / build (push) Failing after 1m41s
2026-05-14 22:58:49 -04:00
jbrechtel 0d8cddb9be converge: auto-commit at 2026-05-14 22:53:35 from zendo.brechtel
Build / build (push) Failing after 1m56s
2026-05-14 22:53:35 -04:00
jbrechtel 9bca886629 converge: auto-commit at 2026-05-14 22:46:10 from zendo.brechtel
Build / build (push) Failing after 1m45s
2026-05-14 22:46:10 -04:00
jbrechtel edc6d5acf3 Ignores arch package files etc 2026-05-14 22:32:35 -04:00
jbrechtel 4473dc9164 Fixes contact and name
Build / build (push) Successful in 1m22s
2026-05-14 22:20:46 -04:00
jbrechtel 29a984934d converge: auto-commit at 2026-05-14 22:15:21 from zendo.brechtel
Build / build (push) Successful in 1m4s
2026-05-14 22:15:21 -04:00
jbrechtel ef49f30c88 Add Arch Linux PKGBUILD and package build step
Build / build (push) Successful in 58s
- PKGBUILD: builds converge package, uses pre-built binary from
  build/converge when available (from scripts/build), falls back to
  stack build from source. Installs binary, systemd user service,
  example config, and README.
- scripts/build: added final step to run makepkg -f on the host
  (conditional, skipped if makepkg not found). Produces .pkg.tar.zst
  alongside the binary.
- scripts/makepkg: convenience wrapper for standalone makepkg runs.
2026-05-14 19:03:07 -04:00
jbrechtel 89e7729f2c converge: disable response timeout on relay SSE connection
Build / build (push) Successful in 48s
tlsManagerSettings defaults to a 30s response timeout, which kills
long-lived SSE connections between push events. Set it to
responseTimeoutNone so the relay watcher waits indefinitely.
2026-05-14 18:10:02 -04:00
jbrechtel 1ad993b9d1 Fix: downgrade upload-artifact to v3 (Gitea/GHES compat)
Build / build (push) Successful in 12m2s
2026-05-14 16:10:42 -04:00
jbrechtel 27f4e1fd2c Fix: use absolute path for STACK_ROOT (Stack 3 requirement)
Build / build (push) Failing after 15m57s
2026-05-14 15:51:00 -04:00
jbrechtel 657855bd41 Fix CI: remove Docker-in-Docker, install Stack directly
Build / build (push) Failing after 9s
The ./hs Docker wrapper runs docker inside the Gitea runner container.
Since project paths inside the runner container don't exist on the Docker
host, the volume mount creates an empty /work directory, and Stack finds
no packages (error S-8506).

Fix: install Stack directly via get.haskellstack.org, use STACK_ROOT for
caching, and build with stack directly (no Docker-in-Docker).

Also add symlink for gitea-actions.sh convenience script.
2026-05-14 15:50:09 -04:00
jbrechtel bdd9e663b9 chore: apply fourmolu formatting
Build / build (push) Failing after 7s
2026-05-14 14:50:23 -04:00
jbrechtel 662854a977 tests: SSE parsing + relay event handling, update AGENTS.md
- 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
2026-05-14 14:40:47 -04:00
jbrechtel 7feff5abfa converge: phase 2 — SSE relay client for realtime pull on push
- RepoConfig gains rcGiteaRepo :: Maybe Text for relay matching
- Options gains optRelayUrl :: Maybe Text (--relay-url CLI, relay_url config)
- connectRelay connects to converge-relay SSE endpoint, parses push events,
  triggers gitPull on matching repos (by gitea_repo + branch)
- Exponential backoff reconnection (1s .. 60s) on disconnect/error
- Skips pull if repo is mid-operation (merge/rebase/etc.)
- Dependencies: http-client, http-client-tls, http-types, aeson, bytestring
- SSE parser handles event:/data: fields, blank-line delimiters, CRLF endings
- Updated SPEC.md, example config, and help text
2026-05-14 14:13:43 -04:00
jbrechtel 25881f3ff8 converge-relay: phase 1 — webhook-to-SSE relay server in Go
- Pub/sub broker with fan-out to SSE clients
- POST /webhook — receives Gitea push events, optional HMAC verification
- GET /events — SSE stream of push events
- GET /health — health check endpoint
- Zero external dependencies (stdlib only)
- Docker-based build via goctl wrapper (golang:1.26-alpine)
- systemd unit, README with deployment docs
- Tests: broker pub/sub, slow subscriber drop, ref parsing, signature verification
- 6.0M static binary
2026-05-14 13:54:26 -04:00
jbrechtel 4f79212c45 Removes permission settings in service - they break ssh pushing
Build / build (push) Failing after 6s
2026-05-14 13:28:34 -04:00
jbrechtel 74b148af20 Tidy: formatting and import order
Build / build (push) Failing after 6s
2026-05-14 11:40:54 -04:00
jbrechtel 477a86c18a Add --sync flag for one-shot sync-and-exit mode
Build / build (push) Failing after 6s
Useful for wake-from-sleep hooks: run 'converge --sync' to commit
any pending changes, pull from remotes, and push, then exit.
Shows 'Syncing N repo(s)' instead of 'Watching N repo(s)'.
2026-05-14 10:34:36 -04:00
jbrechtel 97078928fc Add per-repo notify_on_pull configuration
Build / build (push) Failing after 10s
- Add rcNotifyOnPull field to RepoConfig (default: False)
- notify_on_pull YAML config key per repository
- When enabled, sends a desktop notification whenever new commits
  are pulled from the remote (with commit count and summaries)
- Uses HEAD comparison to detect actual new commits, not just
  successful pull execution
- Updated example config and spec
2026-05-14 10:20:24 -04:00
jbrechtel f66dfcbccb Add configurable log levels and log file support
Build / build (push) Failing after 7s
- New LogLevel type: Debug, Info, Warn, Error with Ord instance
- Logger holds configured level and output handle (stderr or file)
- --log-level and --log-file CLI flags
- log_level and log_file YAML config fields
- Log messages assigned appropriate severity levels
- Test logger updated to new Logger structure
- All 17 tests passing
2026-05-14 10:13:36 -04:00
32 changed files with 1672 additions and 78 deletions
+57 -9
View File
@@ -9,24 +9,26 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
CONVERGE_HASKELL_TOOLS_IMAGE: ghcr.io/flipstone/haskell-tools:debian-ghc-9.10.3-5d6640d STACK_ROOT: ${{ github.workspace }}/.stack-root
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
path: . - name: Install Stack
run: |
curl -sSL https://get.haskellstack.org/ | sh
- name: Cache Stack - name: Cache Stack
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: .stack-root path: .stack-root
key: stack-${{ runner.os }}-${{ hashFiles('stack.yaml', 'package.yaml') }} key: stack-v2-${{ runner.os }}-${{ hashFiles('stack.yaml', 'package.yaml') }}
restore-keys: stack-${{ runner.os }}- restore-keys: stack-v2-${{ runner.os }}-
- name: Build - name: Build
run: | run: |
mkdir -p .stack-root build mkdir -p build
./hs stack build --copy-bins --local-bin-path /work/build stack build --copy-bins --local-bin-path ./build
- name: Compress with UPX - name: Compress with UPX
run: | run: |
@@ -39,9 +41,55 @@ jobs:
fi fi
${UPX_DIR}/upx build/converge ${UPX_DIR}/upx build/converge
- name: Build Arch package
run: |
# Build the package inside an Arch Linux container.
# Pipe workspace via stdin to avoid Docker-in-Docker volume mount issues.
# All build output goes to stderr (visible in logs); only the package
# tar goes to stdout so it can be captured cleanly.
tar cf - --exclude=.stack-root --exclude=.stack-work . | \
docker run --rm -i -w /build archlinux:latest bash -c "
mkdir -p /build &&
cd /build &&
tar xf - &&
pacman -Syu --noconfirm >&2 &&
pacman -S --noconfirm git fakeroot debugedit >&2 &&
useradd -m build &&
chown -R build:build . &&
runuser -u build -- makepkg --noconfirm --nodeps --nocheck >&2 &&
tar cf /dev/stdout converge-*.pkg.tar.zst
" > /tmp/packages.tar
tar xf /tmp/packages.tar
echo "pkgfile=$(ls converge-[0-9]*.pkg.tar.zst)" >> "$GITHUB_ENV"
- name: Publish to Arch repo
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
SSH_AUTH_SOCK: /dev/null
run: |
# Set up SSH
mkdir -p "$HOME/.ssh"
echo "${{ secrets.ARCH_REPO_SSH_KEY }}" > "$HOME/.ssh/id_ed25519"
chmod 600 "$HOME/.ssh/id_ed25519"
ssh-keyscan -H "${{ vars.ARCH_REPO_HOST }}" >> "$HOME/.ssh/known_hosts" 2>/dev/null
PKGFILE="${{ env.pkgfile }}"
echo "Uploading $PKGFILE to ${{ vars.ARCH_REPO_HOST }}..."
# Upload the package
scp -v -i "$HOME/.ssh/id_ed25519" \
"$PKGFILE" \
"${{ vars.ARCH_REPO_USER }}@${{ vars.ARCH_REPO_HOST }}:${{ vars.ARCH_REPO_PATH }}/"
# Add to repo database
ssh -v -i "$HOME/.ssh/id_ed25519" \
"${{ vars.ARCH_REPO_USER }}@${{ vars.ARCH_REPO_HOST }}" \
"cd '${{ vars.ARCH_REPO_PATH }}' && \
repo-add '${{ vars.ARCH_REPO_DB }}' '$PKGFILE' && \
rm -f '$PKGFILE.old'"
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: converge name: converge
path: build/converge path: build/converge
compression-level: 0
+4
View File
@@ -10,3 +10,7 @@ dist/
dist-newstyle/ dist-newstyle/
.claude/ .claude/
.pi/ .pi/
converge-relay/.go-cache/
converge-relay/build/
*.pkg.tar
pkg/
+45 -2
View File
@@ -20,7 +20,7 @@ test/GitPullSpec.hs # Pull/rebase tests
test/GitSafetySpec.hs # Operation-in-progress detection tests test/GitSafetySpec.hs # Operation-in-progress detection tests
SPEC.md # Human-readable specifications SPEC.md # Human-readable specifications
converge.example.yaml # Example config file converge.example.yaml # Example config file
converge.service # systemd unit file converge.service # systemd user service (daemon mode)
``` ```
## Architecture ## Architecture
@@ -34,6 +34,25 @@ All core logic lives in the single `Converge` module (`src/Converge.hs`). Key se
- **Sync cycle** (`syncRepo`): guarded by `isInMiddleOfOperation`, then stage + commit + pull + conflict check - **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 - **Git operations**: shell out to `git` via `System.Process.readProcessWithExitCode` — no libgit2 dependency
- **Notifications**: shell out to `notify-send` via `System.Process.spawnProcess` - **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 ### Git operations
@@ -62,6 +81,8 @@ All core logic lives in the single `Converge` module (`src/Converge.hs`). Key se
## Build / test / run ## Build / test / run
### Haskell (converge)
```bash ```bash
stack build # Build library + executable + tests stack build # Build library + executable + tests
stack test # Build and run the test suite stack test # Build and run the test suite
@@ -71,6 +92,15 @@ stack exec converge -- [options] # Run the tool directly
./scripts/run # Run the tool (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 ## Test conventions
Tests use **hspec** and create real temporary git repositories — no mocking of git commands. This avoids complexity and tests actual git semantics. Tests use **hspec** and create real temporary git repositories — no mocking of git commands. This avoids complexity and tests actual git semantics.
@@ -105,6 +135,7 @@ Anytime new behavior is added the `SPEC.md` should be updated and a correspondin
| Pulls from remote origin | `GitPullSpec` | Fast-forward pull scenario | | Pulls from remote origin | `GitPullSpec` | Fast-forward pull scenario |
| Rebases on pull | `GitPullSpec` | Divergent history → linear history, no merge commits | | Rebases on pull | `GitPullSpec` | Divergent history → linear history, no merge commits |
| No-op during operations | `GitSafetySpec` | All 7 sentinel types + clean repo | | 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 ## Key conventions
@@ -117,17 +148,29 @@ Anytime new behavior is added the `SPEC.md` should be updated and a correspondin
## Dependencies ## Dependencies
### Haskell
| Package | Purpose | | Package | Purpose |
|---|---| |---|---|
| `async` | Concurrent repo watching | | `async` | Concurrent repo watching |
| `fsnotify` | File system event monitoring | | `fsnotify` | File system event monitoring |
| `optparse-applicative` | CLI argument parsing | | `optparse-applicative` | CLI argument parsing |
| `yaml` | Config file 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 | | `temporary` (test only) | Temp directories for test repos |
| `hspec` (test only) | Test framework | | `hspec` (test only) | Test framework |
### Go (converge-relay)
Zero external dependencies — stdlib only (`net/http`, `log/slog`, `crypto/hmac`, `encoding/json`, `sync`).
## External requirements ## External requirements
- `git` must be on PATH (no libgit2 dependency) - `git` must be on PATH (no libgit2 dependency)
- `notify-send` for desktop notifications (optional — only called on conflict) - `notify-send` for desktop notifications (optional — only called on conflict/pull)
- `hostname` command for commit message generation - `hostname` command for commit message generation
- For realtime pull: a running `converge-relay` instance reachable via HTTP(S)
+68
View File
@@ -0,0 +1,68 @@
# Maintainer: James Brechtel <james@leonard-brechtel.com.com>
# Contributor: James Brechtel <james@leonard-brechtel.com.com>
pkgname=converge
_pkgver=0.2.0.0
pkgver=${_pkgver//-/_}
pkgrel=1
pkgdesc="Auto-sync git repositories by watching for changes, committing, pulling, and pushing"
arch=('x86_64')
url="https://github.com/jbrechtel/converge"
license=('BSD')
makedepends=('stack' 'git')
depends=('git' 'libnotify' 'gcc-libs')
optdepends=('converge-relay: realtime push notifications via SSE relay server')
backup=('etc/converge/config.example.yaml')
source=(
"converge.service"
"converge.example.yaml"
)
md5sums=(
'SKIP'
'SKIP'
)
build() {
cd "$startdir"
echo "$startdir"
# Use pre-built binary if available (e.g. from scripts/build), otherwise build from source
if [[ -x build/converge ]]; then
msg "Using pre-built binary from build/converge"
install -Dm755 build/converge "$srcdir/bin/converge"
else
export STACK_ROOT="$srcdir/.stack-root"
msg "Building converge with stack (resolver: $(awk '/^resolver:/ {print $2}' stack.yaml))..."
stack build \
--copy-bins \
--local-bin-path "$srcdir/bin" \
--ghc-options '-O2'
fi
}
check() {
cd "$startdir"
export STACK_ROOT="$srcdir/.stack-root"
msg "Running tests..."
stack test
}
package() {
cd "$startdir"
# Binary
install -Dm755 "$srcdir/bin/converge" \
"$pkgdir/usr/bin/converge"
# User systemd services
install -Dm644 converge.service \
"$pkgdir/usr/lib/systemd/user/converge.service"
# Example config
install -Dm644 converge.example.yaml \
"$pkgdir/etc/converge/config.example.yaml"
# Documentation
install -Dm644 README.md \
"$pkgdir/usr/share/doc/converge/README.md"
}
+11
View File
@@ -12,3 +12,14 @@
### a revert is in progess ### a revert is in progess
### a cherry-pick is in progess ### a cherry-pick is in progess
### a bisect is in progess ### a bisect is in progess
## Supports configurable log levels (debug, info, warn, error)
## Supports logging to a file instead of stderr via --log-file or log_file config
## Can notify on pull: per-repo notify_on_pull setting sends a desktop notification when new commits are pulled from the remote
## --sync / -s flag runs a single sync cycle (commit + pull + push) and exits
## Can connect to a converge-relay SSE endpoint and automatically pull when push events arrive
### Configured via --relay-url CLI flag or relay_url config file entry
### Matches repos by gitea_repo field (Gitea repository full_name) and branch
### Reconnects automatically with exponential backoff on disconnect or error
+78 -8
View File
@@ -3,6 +3,7 @@ module Main (main) where
import Converge import Converge
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T
import Options.Applicative import Options.Applicative
import System.IO (hPutStrLn, stderr) import System.IO (hPutStrLn, stderr)
@@ -17,6 +18,14 @@ data CliArgs = CliArgs
-- ^ Branch name (used only in single-repo mode). -- ^ Branch name (used only in single-repo mode).
, cliDebounce :: !(Maybe Int) , cliDebounce :: !(Maybe Int)
-- ^ Debounce override for both modes. -- ^ Debounce override for both modes.
, cliLogLevel :: !(Maybe LogLevel)
-- ^ Log level override.
, cliLogFile :: !(Maybe FilePath)
-- ^ Log file path.
, cliSync :: !Bool
-- ^ Run a single sync cycle and exit (useful for wake-from-sleep hooks).
, cliRelayUrl :: !(Maybe Text)
-- ^ URL of a converge-relay SSE endpoint (overrides config).
} }
cliParser :: Parser CliArgs cliParser :: Parser CliArgs
@@ -62,6 +71,41 @@ cliParser =
<> help "Debounce window in milliseconds (overrides config file)" <> help "Debounce window in milliseconds (overrides config file)"
) )
) )
<*> optional
( option
( maybeReader
( \s -> case T.toLower (T.pack s) of
"debug" -> Just Debug
"info" -> Just Info
"warn" -> Just Warn
"error" -> Just Error
_ -> Nothing
)
)
( long "log-level"
<> metavar "LEVEL"
<> help "Minimum log level: debug, info, warn, or error (default: info)"
)
)
<*> optional
( strOption
( long "log-file"
<> metavar "FILE"
<> help "Write logs to a file instead of stderr"
)
)
<*> switch
( long "sync"
<> short 's'
<> help "Run a single sync cycle (commit + pull + push) and exit"
)
<*> optional
( strOption
( long "relay-url"
<> metavar "URL"
<> help "URL of a converge-relay SSE endpoint for realtime push notifications"
)
)
parserInfo :: ParserInfo CliArgs parserInfo :: ParserInfo CliArgs
parserInfo = parserInfo =
@@ -79,7 +123,7 @@ main = do
(options, configSource) <- case cliConfig cli of (options, configSource) <- case cliConfig cli of
Just cfgPath -> do Just cfgPath -> do
cfg <- loadConfig cfgPath cfg <- loadConfig cfgPath
let opts = applyDebounce mDebounceOverride (configToOptions cfg) let opts = applyOverrides mDebounceOverride (cliLogLevel cli) (cliLogFile cli) (cliRelayUrl cli) (configToOptions cfg)
pure (opts, Just cfgPath) pure (opts, Just cfgPath)
Nothing -> do Nothing -> do
exists <- configFileExists exists <- configFileExists
@@ -87,11 +131,16 @@ main = do
then do then do
cfgPath <- defaultConfigPath cfgPath <- defaultConfigPath
cfg <- loadConfig cfgPath cfg <- loadConfig cfgPath
let opts = applyDebounce mDebounceOverride (configToOptions cfg) let opts = applyOverrides mDebounceOverride (cliLogLevel cli) (cliLogFile cli) (cliRelayUrl cli) (configToOptions cfg)
pure (opts, Just cfgPath) pure (opts, Just cfgPath)
else else
pure pure
( defaultOptions ( applyOverrides
mDebounceOverride
(cliLogLevel cli)
(cliLogFile cli)
(cliRelayUrl cli)
defaultOptions
{ optRepos = { optRepos =
[ defaultRepoConfig [ defaultRepoConfig
{ rcPath = fromMaybe "." (cliRepo cli) { rcPath = fromMaybe "." (cliRepo cli)
@@ -108,16 +157,37 @@ main = do
hPutStrLn stderr ("Using config: " <> path) hPutStrLn stderr ("Using config: " <> path)
Nothing -> Nothing ->
hPutStrLn stderr "No config file found, using single-repo mode" hPutStrLn stderr "No config file found, using single-repo mode"
let repos = optRepos options
if cliSync cli
then do
hPutStrLn stderr $
"Syncing "
<> show (length repos)
<> " repo(s)"
mapM_ (\r -> hPutStrLn stderr (" - " <> rcPath r)) repos
logger <- newLogger (optLogLevel options) (optLogFile options)
mapM_ (syncRepo options logger) repos
else do
hPutStrLn stderr $ hPutStrLn stderr $
"Watching " "Watching "
<> show (length (optRepos options)) <> show (length repos)
<> " repo(s)" <> " repo(s)"
<> " (debounce: " <> " (debounce: "
<> show (optDebounceMs options) <> show (optDebounceMs options)
<> "ms)" <> "ms)"
mapM_ (\r -> hPutStrLn stderr (" - " <> rcPath r)) (optRepos options) mapM_ (\r -> hPutStrLn stderr (" - " <> rcPath r)) repos
runConverge options runConverge options
applyDebounce :: Maybe Int -> Options -> Options -- | Apply CLI overrides (debounce, log level, log file, relay url) to Options.
applyDebounce (Just ms) opts = opts{optDebounceMs = ms} applyOverrides :: Maybe Int -> Maybe LogLevel -> Maybe FilePath -> Maybe Text -> Options -> Options
applyDebounce Nothing opts = opts applyOverrides mDebounce mLogLevel mLogFile mRelayUrl opts =
opts
{ optDebounceMs = maybe (optDebounceMs opts) id mDebounce
, optLogLevel = fromMaybe (optLogLevel opts) mLogLevel
, optLogFile = case mLogFile of
Just _ -> mLogFile -- CLI --log-file overrides config
Nothing -> optLogFile opts
, optRelayUrl = case mRelayUrl of
Just _ -> mRelayUrl -- CLI --relay-url overrides config
Nothing -> optRelayUrl opts
}
+89
View File
@@ -0,0 +1,89 @@
# converge-relay
Webhook-to-SSE relay for [converge](https://github.com/jbrechtel/converge). Receives Gitea push webhooks and fans them out to connected converge clients via Server-Sent Events.
## How it works
```
┌─────────────┐ POST /webhook ┌──────────────────┐ GET /events (SSE) ┌──────────────────┐
│ Gitea │ ────────────────> │ converge-relay │ ──────────────────> │ converge (local) │
│ Server │ (push event) │ (this server) │ event: push │ │
└─────────────┘ └──────────────────┘ └──────────────────┘
```
1. Configure Gitea to send push webhooks to `https://your-server:8080/webhook`
2. Run `converge-relay` on a publicly reachable server (or behind nginx)
3. Point your local `converge` at the relay's SSE endpoint with `--relay-url http://your-server:8080/events`
## Build & run (Docker-only, no host Go tools required)
```bash
# Build (formats, tests, compiles) → ./build/converge-relay
./scripts/build
# Run tests only
./scripts/test
# Run in development
./scripts/run --listen :8080
# Run go commands directly
./goctl go fmt ./...
./goctl go mod tidy
```
## Configuration
| Flag | Env var | Default | Description |
|------|---------|---------|-------------|
| `--listen` | `CONVERGE_RELAY_LISTEN` | `:8080` | Address to listen on |
| `--secret` | `CONVERGE_RELAY_SECRET` | `""` | Shared secret for HMAC webhook verification |
## Endpoints
### `POST /webhook`
Receives Gitea push webhooks. Validates `X-Gitea-Signature` or `X-Hub-Signature-256` against the configured secret. Extracts `repository.full_name` and branch name from `ref`.
### `GET /events`
SSE stream. Clients connect and receive push events as they arrive:
```
event: push
data: {"full_name":"myorg/myrepo","branch":"main","ref":"refs/heads/main"}
```
Automatic reconnection with `Last-Event-ID` is supported. When the connection drops, clients reconnect with exponential backoff.
### `GET /health`
Returns `ok\n` — useful for load balancer health checks.
## Deployment
```bash
# Build
./scripts/build
# Install
sudo cp build/converge-relay /usr/local/bin/
# Install and enable the systemd service
sudo cp converge-relay.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now converge-relay
```
For production, set the `CONVERGE_RELAY_SECRET` environment variable in the service file or drop-in.
## Gitea webhook setup
1. Go to your repository → Settings → Webhooks → Add Webhook → Gitea
2. Target URL: `https://your-server:8080/webhook`
3. Secret: same value as `CONVERGE_RELAY_SECRET`
4. Events: Push events only
## Docker image
Built via `golang:1.26-alpine`. No external Go dependencies — stdlib only.
+94
View File
@@ -0,0 +1,94 @@
package main
import "log/slog"
// PushEvent is broadcast to all SSE subscribers when a webhook is received.
// It contains just enough information for converge clients to decide whether
// to pull.
type PushEvent struct {
FullName string `json:"full_name"` // e.g. "myorg/myrepo"
Branch string `json:"branch"` // e.g. "main"
Ref string `json:"ref"` // e.g. "refs/heads/main"
}
// Broker implements a pub/sub fan-out for PushEvents.
// Subscribe to get a channel that receives events; unsubscribe when done.
// Slow subscribers (channel full) are silently dropped for that event.
type Broker struct {
subscribe chan chan PushEvent
unsubscribe chan chan PushEvent
publish chan PushEvent
stop chan struct{}
subs map[chan PushEvent]struct{}
}
// NewBroker creates a ready-to-run Broker.
func NewBroker() *Broker {
return &Broker{
subscribe: make(chan chan PushEvent),
unsubscribe: make(chan chan PushEvent),
publish: make(chan PushEvent, 64),
stop: make(chan struct{}),
subs: make(map[chan PushEvent]struct{}),
}
}
// Run starts the broker event loop. Must be called in its own goroutine.
func (b *Broker) Run() {
for {
select {
case ch := <-b.subscribe:
b.subs[ch] = struct{}{}
slog.Debug("subscriber connected", "total", len(b.subs))
case ch := <-b.unsubscribe:
delete(b.subs, ch)
// Drain and close the channel so readers exit cleanly
close(ch)
for range ch {
}
slog.Debug("subscriber disconnected", "total", len(b.subs))
case evt := <-b.publish:
slog.Info("publishing event", "full_name", evt.FullName, "branch", evt.Branch)
for ch := range b.subs {
select {
case ch <- evt:
default:
// Subscriber too slow; drop the event for this subscriber
slog.Warn("dropping event for slow subscriber")
}
}
case <-b.stop:
for ch := range b.subs {
close(ch)
}
return
}
}
}
// Subscribe returns a buffered channel that receives PushEvents.
func (b *Broker) Subscribe() chan PushEvent {
// Small buffer to smooth out delivery; slow consumers are dropped by
// the broker's non-blocking publish anyway.
ch := make(chan PushEvent, 16)
b.subscribe <- ch
return ch
}
// Unsubscribe removes a subscriber channel.
func (b *Broker) Unsubscribe(ch chan PushEvent) {
b.unsubscribe <- ch
}
// Publish sends an event to all current subscribers. Never blocks.
func (b *Broker) Publish(evt PushEvent) {
b.publish <- evt
}
// Stop shuts down the broker.
func (b *Broker) Stop() {
close(b.stop)
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"testing"
"time"
)
func TestBrokerSubscribePublishUnsubscribe(t *testing.T) {
broker := NewBroker()
go broker.Run()
defer broker.Stop()
ch1 := broker.Subscribe()
ch2 := broker.Subscribe()
evt := PushEvent{FullName: "org/repo", Branch: "main", Ref: "refs/heads/main"}
// Publish
broker.Publish(evt)
// Both subscribers should receive
select {
case got := <-ch1:
if got != evt {
t.Errorf("ch1: got %+v, want %+v", got, evt)
}
case <-time.After(time.Second):
t.Fatal("ch1: timed out waiting for event")
}
select {
case got := <-ch2:
if got != evt {
t.Errorf("ch2: got %+v, want %+v", got, evt)
}
case <-time.After(time.Second):
t.Fatal("ch2: timed out waiting for event")
}
// Unsubscribe ch2, publish again
broker.Unsubscribe(ch2)
// Drain ch2 (Unsubscribe closes it)
for range ch2 {
}
evt2 := PushEvent{FullName: "org/repo2", Branch: "dev", Ref: "refs/heads/dev"}
broker.Publish(evt2)
// ch1 gets it
select {
case got := <-ch1:
if got != evt2 {
t.Errorf("ch1 second: got %+v, want %+v", got, evt2)
}
case <-time.After(time.Second):
t.Fatal("ch1: timed out waiting for second event")
}
// ch2 is closed, should not receive
_, open := <-ch2
if open {
t.Error("ch2 should be closed after unsubscribe")
}
}
func TestBrokerSlowSubscriberDropped(t *testing.T) {
broker := NewBroker()
go broker.Run()
defer broker.Stop()
// Channel with buffer 0 — publish will always fail to send
ch := make(chan PushEvent)
broker.subs[ch] = struct{}{} // bypass Subscribe for manual control
evt := PushEvent{FullName: "org/repo", Branch: "main"}
broker.Publish(evt)
// Give it time to process
time.Sleep(50 * time.Millisecond)
// Should not have blocked the broker; Publish returned fine
// and we didn't deadlock.
select {
case <-ch:
t.Error("slow subscriber should have been dropped")
default:
// expected: no event on unbuffered channel
}
// ch is closed by broker.Stop() via defer
}
func TestRefToBranch(t *testing.T) {
tests := []struct {
ref string
want string
}{
{"refs/heads/main", "main"},
{"refs/heads/feature/x", "feature/x"},
{"refs/tags/v1.0", ""},
{"refs/heads/", ""},
{"main", ""},
}
for _, tt := range tests {
got := refToBranch(tt.ref)
if got != tt.want {
t.Errorf("refToBranch(%q) = %q, want %q", tt.ref, got, tt.want)
}
}
}
func TestVerifySignature(t *testing.T) {
secret := "test-secret"
body := []byte(`{"ref":"refs/heads/main"}`)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
validSig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
headers := http.Header{}
headers.Set("X-Gitea-Signature", validSig)
if !verifySignature(body, secret, headers) {
t.Error("valid signature should pass")
}
headers.Set("X-Gitea-Signature", "sha256=bad")
if verifySignature(body, secret, headers) {
t.Error("bad signature should fail")
}
headers.Del("X-Gitea-Signature")
headers.Set("X-Hub-Signature-256", validSig)
if !verifySignature(body, secret, headers) {
t.Error("X-Hub-Signature-256 with valid signature should pass")
}
}
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Converge relay - webhook-to-SSE relay server
Documentation=https://github.com/jbrechtel/converge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%h/.local/bin/converge-relay --listen :8080
Restart=on-failure
RestartSec=5
# Set a shared secret for webhook HMAC verification (optional but recommended).
# Generate with: openssl rand -hex 32
# Environment=CONVERGE_RELAY_SECRET=your-secret-here
[Install]
WantedBy=default.target
+3
View File
@@ -0,0 +1,3 @@
module github.com/jbrechtel/converge-relay
go 1.26
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Thin wrapper to run Go tooling inside a golang:1.26-alpine Docker container.
# Usage: ./goctl go build ./..., ./goctl go test ./..., ./goctl go fmt ./...
set -euo pipefail
IMAGE="${CONVERGE_GO_IMAGE:-golang:1.26-alpine}"
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_CACHE="${PROJECT_DIR}/.go-cache"
mkdir -p "${GO_CACHE}/mod" "${GO_CACHE}/build"
exec docker run --rm -i $([ -t 0 ] && printf -- -t) \
-v "${PROJECT_DIR}:/work" \
-v "${GO_CACHE}/mod:/go/pkg/mod" \
-v "${GO_CACHE}/build:/root/.cache/go-build" \
-e GOMODCACHE=/go/pkg/mod \
-e GOCACHE=/root/.cache/go-build \
-w /work \
"${IMAGE}" \
"$@"
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"errors"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
)
func main() {
listen := flag.String("listen", envDefault("CONVERGE_RELAY_LISTEN", ":8080"), "address to listen on")
secret := flag.String("secret", envDefault("CONVERGE_RELAY_SECRET", ""), "shared secret for Gitea webhook HMAC verification")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
broker := NewBroker()
go broker.Run()
mux := http.NewServeMux()
mux.HandleFunc("POST /webhook", webhookHandler(broker, *secret))
mux.HandleFunc("GET /events", sseHandler(broker))
mux.HandleFunc("GET /health", healthHandler)
srv := &http.Server{Addr: *listen, Handler: mux}
// Graceful shutdown
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
<-sig
slog.Info("shutting down")
broker.Stop()
srv.Shutdown(context.Background())
}()
slog.Info("converge-relay starting", "listen", *listen)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "error", err)
os.Exit(1)
}
}
func envDefault(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("ok\n"))
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
echo "Formatting..."
./goctl go fmt ./...
echo "Running tests..."
./goctl go test ./...
echo "Building..."
./goctl go build -ldflags="-s -w" -o /work/build/converge-relay .
echo "Done: $(ls -lh build/converge-relay | awk '{print $5}')"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
./goctl go run . "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
./goctl go test -v ./...
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
)
func sseHandler(broker *Broker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering
ch := broker.Subscribe()
defer broker.Unsubscribe(ch)
slog.Debug("SSE client connected")
ctx := r.Context()
for {
select {
case <-ctx.Done():
slog.Debug("SSE client disconnected")
return
case evt, ok := <-ch:
if !ok {
// Broker shut down
return
}
writeSSEEvent(w, flusher, "push", evt)
}
}
}
}
func writeSSEEvent(w http.ResponseWriter, flusher http.Flusher, eventType string, data any) {
jsonData, err := json.Marshal(data)
if err != nil {
slog.Warn("failed to marshal SSE event", "error", err)
return
}
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, jsonData)
if err != nil {
slog.Debug("failed to write SSE event, client likely disconnected", "error", err)
return
}
flusher.Flush()
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
)
// giteaPushPayload mirrors the relevant fields from Gitea's push webhook JSON.
type giteaPushPayload struct {
Ref string `json:"ref"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
func webhookHandler(broker *Broker, secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB limit
if err != nil {
slog.Warn("failed to read webhook body", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Verify signature if a secret is configured
if secret != "" && !verifySignature(body, secret, r.Header) {
slog.Warn("webhook signature verification failed")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Only handle push events
eventType := r.Header.Get("X-Gitea-Event")
if eventType != "" && eventType != "push" {
slog.Debug("ignoring non-push event", "type", eventType)
w.WriteHeader(http.StatusOK)
return
}
var payload giteaPushPayload
if err := json.Unmarshal(body, &payload); err != nil {
slog.Warn("failed to parse webhook payload", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
branch := refToBranch(payload.Ref)
if branch == "" {
slog.Debug("ignoring non-branch ref", "ref", payload.Ref)
w.WriteHeader(http.StatusOK)
return
}
broker.Publish(PushEvent{
FullName: payload.Repository.FullName,
Branch: branch,
Ref: payload.Ref,
})
w.WriteHeader(http.StatusOK)
}
}
// verifySignature checks the request body against the HMAC-SHA256 signature
// in X-Gitea-Signature or X-Hub-Signature-256 headers.
func verifySignature(body []byte, secret string, headers http.Header) bool {
sig := headers.Get("X-Gitea-Signature")
if sig == "" {
sig = headers.Get("X-Hub-Signature-256")
}
if sig == "" {
return false
}
// Strip "sha256=" prefix if present
sig = strings.TrimPrefix(sig, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
// refToBranch extracts the branch name from a git ref like "refs/heads/main".
func refToBranch(ref string) string {
const prefix = "refs/heads/"
if strings.HasPrefix(ref, prefix) {
return ref[len(prefix):]
}
return ""
}
+127
View File
@@ -0,0 +1,127 @@
cabal-version: 2.2
-- This file has been generated from package.yaml by hpack version 0.38.1.
--
-- see: https://github.com/sol/hpack
name: converge
version: 0.1.0.1
synopsis: Auto-sync a git repository by watching for changes, committing, and pulling
description: Converge watches a git repository for file changes, automatically
commits them, and pulls from the remote. It notifies the user via
libnotify/notify-send when merge conflicts arise.
author: James Leonard-Brechtel
maintainer: james@leonard-brechtel.com
copyright: 2026 James Leonard-Brechtel
license: BSD-3-Clause
build-type: Simple
library
exposed-modules:
Converge
other-modules:
Paths_converge
autogen-modules:
Paths_converge
hs-source-dirs:
src
default-extensions:
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
build-depends:
aeson
, async
, base >=4.7 && <5
, bytestring
, directory
, filepath
, fsnotify
, http-client
, http-client-tls
, http-types
, optparse-applicative
, process
, text
, time
, unix
, yaml
default-language: Haskell2010
executable converge
main-is: Main.hs
other-modules:
Paths_converge
autogen-modules:
Paths_converge
hs-source-dirs:
app
default-extensions:
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 -threaded -rtsopts -with-rtsopts=-N
build-depends:
aeson
, async
, base >=4.7 && <5
, bytestring
, converge
, directory
, filepath
, fsnotify
, http-client
, http-client-tls
, http-types
, optparse-applicative
, process
, text
, time
, unix
, yaml
default-language: Haskell2010
test-suite converge-test
type: exitcode-stdio-1.0
main-is: Spec.hs
other-modules:
GitCommitSpec
GitPullSpec
GitSafetySpec
RelaySpec
TestHelper
Paths_converge
autogen-modules:
Paths_converge
hs-source-dirs:
test
default-extensions:
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 -threaded -rtsopts -with-rtsopts=-N
build-depends:
aeson
, async
, base >=4.7 && <5
, bytestring
, converge
, directory
, filepath
, fsnotify
, hspec
, http-client
, http-client-tls
, http-types
, optparse-applicative
, process
, temporary
, text
, time
, unix
, yaml
default-language: Haskell2010
+8
View File
@@ -2,12 +2,20 @@
# Place at ~/.config/converge/config.yaml to use by default, # Place at ~/.config/converge/config.yaml to use by default,
# or pass explicitly with: converge --config converge.yaml # or pass explicitly with: converge --config converge.yaml
# relay_url: "http://localhost:8080/events" # converge-relay SSE endpoint for realtime push notifications
repos: repos:
- path: /home/jbrechtel/org - path: /home/jbrechtel/org
remote: origin remote: origin
branch: main branch: main
# gitea_repo: "myorg/org-repo" # Gitea repository full_name for relay matching
# notify_on_pull: true # send a notification when new commits are pulled
- path: /work/personal/hawat - path: /work/personal/hawat
# remote defaults to "origin" # remote defaults to "origin"
# branch defaults to "main" # branch defaults to "main"
# gitea_repo: "myorg/hawat"
# debounce_ms: 5000 # optional, defaults to 5000 # debounce_ms: 5000 # optional, defaults to 5000
# log_level: info # optional, one of: debug, info, warn, error (default: info)
# log_file: /var/log/converge.log # optional, write logs to a file instead of stderr
+1 -5
View File
@@ -6,14 +6,10 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=%h/.local/bin/converge --config %h/.config/converge/config.yaml ExecStart=/usr/bin/converge --config %h/.config/converge/config.yaml
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
# Security hardening
NoNewPrivileges=yes
PrivateTmp=yes
# Allow notify-send to reach the desktop session # Allow notify-send to reach the desktop session
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
+9 -4
View File
@@ -1,13 +1,13 @@
name: converge name: converge
version: 0.1.0.0 version: 0.1.0.1
synopsis: Auto-sync a git repository by watching for changes, committing, and pulling synopsis: Auto-sync a git repository by watching for changes, committing, and pulling
description: | description: |
Converge watches a git repository for file changes, automatically Converge watches a git repository for file changes, automatically
commits them, and pulls from the remote. It notifies the user via commits them, and pulls from the remote. It notifies the user via
libnotify/notify-send when merge conflicts arise. libnotify/notify-send when merge conflicts arise.
author: James Brechtel author: James Leonard-Brechtel
maintainer: james@flipstone.com maintainer: james@leonard-brechtel.com
copyright: 2026 James Brechtel copyright: 2026 James Leonard-Brechtel
license: BSD-3-Clause license: BSD-3-Clause
default-extensions: default-extensions:
@@ -29,10 +29,15 @@ ghc-options:
dependencies: dependencies:
- base >= 4.7 && < 5 - base >= 4.7 && < 5
- aeson
- async - async
- bytestring
- directory - directory
- filepath - filepath
- fsnotify - fsnotify
- http-client
- http-client-tls
- http-types
- optparse-applicative - optparse-applicative
- process - process
- text - text
+12
View File
@@ -20,3 +20,15 @@ echo "Compressing with UPX..."
' '
echo "Done: $(ls -lh build/converge | awk '{print $5}')" echo "Done: $(ls -lh build/converge | awk '{print $5}')"
# Build Arch Linux package (if makepkg is available)
if command -v makepkg &>/dev/null; then
echo "Building Arch package with makepkg..."
if makepkg -f 2>&1; then
echo "Package: $(ls -1 converge-*.pkg.tar.zst 2>/dev/null)"
else
echo "WARNING: makepkg failed (see above)" >&2
fi
else
echo "Skipping package build (makepkg not found)"
fi
+1
View File
@@ -0,0 +1 @@
../.pi/skills/gitea-actions/scripts/gitea-actions.sh
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Build an Arch Linux package for converge using makepkg.
#
# Usage:
# ./scripts/makepkg # Build the package (no install)
# ./scripts/makepkg -i # Build and install
# ./scripts/makepkg -s # Build and install missing deps
#
# The resulting .pkg.tar.zst will be placed in the project root.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
makepkg -f "$@"
+285 -27
View File
@@ -2,6 +2,7 @@ module Converge (
-- * Core types -- * Core types
Options (..), Options (..),
RepoConfig (..), RepoConfig (..),
LogLevel (..),
defaultOptions, defaultOptions,
defaultRepoConfig, defaultRepoConfig,
@@ -34,25 +35,41 @@ module Converge (
-- * File filtering -- * File filtering
isGitIgnored, isGitIgnored,
-- * Relay listener (SSE client)
PushEvent (..),
connectRelay,
extractLines,
foldLines,
parseEvent,
handleSSEEvent,
) )
where where
import Control.Applicative ((<|>))
import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.Async (mapConcurrently_) import Control.Concurrent.Async (mapConcurrently_)
import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, takeMVar, tryPutMVar, tryTakeMVar, withMVar) import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, takeMVar, tryPutMVar, tryTakeMVar, withMVar)
import Control.Exception (bracket) import Control.Exception (SomeException, bracket, try)
import Control.Monad (forever, unless, void, when) import Control.Monad (forever, unless, void, when)
import Data.Aeson (decode)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as BL
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8)
import Data.Time.Clock (getCurrentTime) import Data.Time.Clock (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Yaml (FromJSON (..), decodeFileThrow, withObject, (.:), (.:?)) import Data.Yaml (FromJSON (..), decodeFileThrow, withObject, withText, (.:), (.:?))
import Network.HTTP.Client
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types.Status (status200)
import System.Directory (XdgDirectory (XdgConfig), doesDirectoryExist, doesFileExist, getCurrentDirectory, getXdgDirectory) import System.Directory (XdgDirectory (XdgConfig), doesDirectoryExist, doesFileExist, getCurrentDirectory, getXdgDirectory)
import System.Exit (ExitCode (..)) import System.Exit (ExitCode (..))
import System.FSNotify import System.FSNotify
import System.FilePath (isAbsolute, splitDirectories, (</>)) import System.FilePath (isAbsolute, splitDirectories, (</>))
import System.IO (hPutStrLn, stderr) import System.IO (BufferMode (..), Handle, IOMode (..), hFlush, hPutStrLn, hSetBuffering, openFile, stderr)
import System.Process (readProcess, readProcessWithExitCode, spawnProcess) import System.Process (readProcess, readProcessWithExitCode, spawnProcess)
-- | Configuration for a single repository to watch. -- | Configuration for a single repository to watch.
@@ -63,9 +80,29 @@ data RepoConfig = RepoConfig
-- ^ Remote name to pull from. -- ^ Remote name to pull from.
, rcBranch :: !Text , rcBranch :: !Text
-- ^ Branch to pull. -- ^ Branch to pull.
, rcNotifyOnPull :: !Bool
-- ^ Send a notification when new commits are pulled from the remote.
, rcGiteaRepo :: !(Maybe Text)
-- ^ Gitea repository full_name (e.g. "myorg/myrepo") for relay matching.
} }
deriving (Show, Eq) deriving (Show, Eq)
-- | Log level controlling verbosity of output.
data LogLevel
= Debug
| Info
| Warn
| Error
deriving (Show, Eq, Ord, Bounded, Enum)
instance FromJSON LogLevel where
parseJSON = withText "LogLevel" $ \t -> case T.toLower t of
"debug" -> pure Debug
"info" -> pure Info
"warn" -> pure Warn
"error" -> pure Error
other -> fail $ "Unknown log level: " <> T.unpack other <> ". Expected: debug, info, warn, or error"
-- | Top-level options for converge. -- | Top-level options for converge.
data Options = Options data Options = Options
{ optRepos :: ![RepoConfig] { optRepos :: ![RepoConfig]
@@ -74,6 +111,12 @@ data Options = Options
-- ^ Debounce window in milliseconds. -- ^ Debounce window in milliseconds.
, optNotifyCmd :: !Text , optNotifyCmd :: !Text
-- ^ Command used for desktop notifications. -- ^ Command used for desktop notifications.
, optLogLevel :: !LogLevel
-- ^ Minimum log level to emit.
, optLogFile :: !(Maybe FilePath)
-- ^ Optional log file path (logs to stderr when Nothing).
, optRelayUrl :: !(Maybe Text)
-- ^ URL of a converge-relay SSE endpoint for realtime push notifications.
} }
deriving (Show, Eq) deriving (Show, Eq)
@@ -84,6 +127,8 @@ defaultRepoConfig =
{ rcPath = "." { rcPath = "."
, rcRemote = "origin" , rcRemote = "origin"
, rcBranch = "main" , rcBranch = "main"
, rcNotifyOnPull = False
, rcGiteaRepo = Nothing
} }
-- | Sensible defaults for options. -- | Sensible defaults for options.
@@ -93,16 +138,32 @@ defaultOptions =
{ optRepos = [defaultRepoConfig] { optRepos = [defaultRepoConfig]
, optDebounceMs = 5000 , optDebounceMs = 5000
, optNotifyCmd = "notify-send" , optNotifyCmd = "notify-send"
, optLogLevel = Info
, optLogFile = Nothing
, optRelayUrl = Nothing
} }
{- | Logger that serializes output to stderr so concurrent repo watchers {- | Logger that serializes output so concurrent repo watchers
don't interleave their log lines. don't interleave their log lines. Writes to stderr or a file
depending on configuration.
-} -}
newtype Logger = Logger (MVar ()) data Logger = Logger
{ loggerLock :: !(MVar ())
, loggerLevel :: !LogLevel
, loggerHandle :: !Handle
}
-- | Create a new thread-safe logger. -- | Create a new thread-safe logger.
newLogger :: IO Logger newLogger :: LogLevel -> Maybe FilePath -> IO Logger
newLogger = Logger <$> newMVar () newLogger lvl mFile = do
lock <- newMVar ()
handle <- case mFile of
Nothing -> pure stderr
Just path -> do
h <- openFile path AppendMode
hSetBuffering h LineBuffering
pure h
pure $ Logger lock lvl handle
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Config file (YAML) -- Config file (YAML)
@@ -112,6 +173,9 @@ newLogger = Logger <$> newMVar ()
data Config = Config data Config = Config
{ cfgRepos :: ![RepoConfig] { cfgRepos :: ![RepoConfig]
, cfgDebounceMs :: !(Maybe Int) , cfgDebounceMs :: !(Maybe Int)
, cfgLogLevel :: !(Maybe LogLevel)
, cfgLogFile :: !(Maybe FilePath)
, cfgRelayUrl :: !(Maybe Text)
} }
deriving (Show, Eq) deriving (Show, Eq)
@@ -120,6 +184,9 @@ instance FromJSON Config where
Config Config
<$> o .: "repos" <$> o .: "repos"
<*> o .:? "debounce_ms" <*> o .:? "debounce_ms"
<*> o .:? "log_level"
<*> o .:? "log_file"
<*> o .:? "relay_url"
instance FromJSON RepoConfig where instance FromJSON RepoConfig where
parseJSON = withObject "RepoConfig" $ \o -> parseJSON = withObject "RepoConfig" $ \o ->
@@ -127,6 +194,8 @@ instance FromJSON RepoConfig where
<$> o .: "path" <$> o .: "path"
<*> (fromMaybe "origin" <$> o .:? "remote") <*> (fromMaybe "origin" <$> o .:? "remote")
<*> (fromMaybe "main" <$> o .:? "branch") <*> (fromMaybe "main" <$> o .:? "branch")
<*> (fromMaybe False <$> o .:? "notify_on_pull")
<*> o .:? "gitea_repo"
-- | Load a YAML config file. -- | Load a YAML config file.
loadConfig :: FilePath -> IO Config loadConfig :: FilePath -> IO Config
@@ -138,6 +207,9 @@ configToOptions cfg =
defaultOptions defaultOptions
{ optRepos = cfgRepos cfg { optRepos = cfgRepos cfg
, optDebounceMs = fromMaybe 5000 (cfgDebounceMs cfg) , optDebounceMs = fromMaybe 5000 (cfgDebounceMs cfg)
, optLogLevel = fromMaybe Info (cfgLogLevel cfg)
, optLogFile = cfgLogFile cfg
, optRelayUrl = cfgRelayUrl cfg
} }
-- | The default XDG config file path: @$XDG_CONFIG_HOME/converge/config.yaml@. -- | The default XDG config file path: @$XDG_CONFIG_HOME/converge/config.yaml@.
@@ -152,16 +224,38 @@ configFileExists = defaultConfigPath >>= doesFileExist
-- Logging -- Logging
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- | Log a timestamped message to stderr. {- | Log a timestamped message at a given level. Messages below the
logInfo :: Logger -> Text -> IO () Logger's configured minimum level are silently dropped.
logInfo (Logger lock) msg = withMVar lock $ \() -> do -}
logMsg :: Logger -> LogLevel -> Text -> IO ()
logMsg (Logger lock lvl handle) level msg =
when (level >= lvl) $
withMVar lock $ \() -> do
now <- getCurrentTime now <- getCurrentTime
let ts = T.pack $ formatTime defaultTimeLocale "%H:%M:%S" now let ts = T.pack $ formatTime defaultTimeLocale "%H:%M:%S" now
hPutStrLn stderr $ "[" <> T.unpack ts <> "] " <> T.unpack msg let levelTag = case level of
Debug -> "DEBUG"
Info -> "INFO "
Warn -> "WARN "
Error -> "ERROR"
hPutStrLn handle $ "[" <> T.unpack ts <> "] " <> levelTag <> " " <> T.unpack msg
hFlush handle
-- | Log a message tagged with a repo path. -- | Log at Debug level.
logDebug :: Logger -> Text -> IO ()
logDebug logger msg = logMsg logger Debug msg
-- | Log at Warn level.
logWarn :: Logger -> Text -> IO ()
logWarn logger msg = logMsg logger Warn msg
-- | Log a message tagged with a repo path (at a given level).
logRepoAt :: Logger -> LogLevel -> RepoConfig -> Text -> IO ()
logRepoAt logger level repo msg = logMsg logger level ("[" <> T.pack (rcPath repo) <> "] " <> msg)
-- | Log a message tagged with a repo path at Info level.
logRepo :: Logger -> RepoConfig -> Text -> IO () logRepo :: Logger -> RepoConfig -> Text -> IO ()
logRepo logger repo msg = logInfo logger ("[" <> T.pack (rcPath repo) <> "] " <> msg) logRepo logger repo msg = logRepoAt logger Info repo msg
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Running -- Running
@@ -170,7 +264,14 @@ logRepo logger repo msg = logInfo logger ("[" <> T.pack (rcPath repo) <> "] " <>
-- | Watch all configured repositories concurrently. -- | Watch all configured repositories concurrently.
runConverge :: Options -> IO () runConverge :: Options -> IO ()
runConverge opts = do runConverge opts = do
logger <- newLogger logger <- newLogger (optLogLevel opts) (optLogFile opts)
-- Start relay listener if configured (runs in background forever)
case optRelayUrl opts of
Just url
| url /= "" -> do
_ <- forkIO $ connectRelay url logger (optRepos opts)
pure ()
_ -> pure ()
mapConcurrently_ (watchRepo opts logger) (optRepos opts) mapConcurrently_ (watchRepo opts logger) (optRepos opts)
{- | Watch a single repository for changes. {- | Watch a single repository for changes.
@@ -183,7 +284,7 @@ watchRepo :: Options -> Logger -> RepoConfig -> IO ()
watchRepo opts logger repo = do watchRepo opts logger repo = do
absPath <- resolvePath (rcPath repo) absPath <- resolvePath (rcPath repo)
let debounceUs = optDebounceMs opts * 1000 let debounceUs = optDebounceMs opts * 1000
logRepo logger repo ("watching " <> T.pack absPath <> " (debounce: " <> T.pack (show (optDebounceMs opts)) <> "ms)") logRepo logger repo ("watching " <> T.pack absPath <> " (debounce: " <> T.pack (show (optDebounceMs opts)) <> "ms) [level=" <> T.pack (show (optLogLevel opts)) <> "]")
pending <- newEmptyMVar pending <- newEmptyMVar
-- Background sync loop: waits for an event signal, debounces, then syncs -- Background sync loop: waits for an event signal, debounces, then syncs
_ <- forkIO $ forever $ do _ <- forkIO $ forever $ do
@@ -246,17 +347,17 @@ merge, rebase, revert, cherry-pick, or bisect.
-} -}
syncRepo :: Options -> Logger -> RepoConfig -> IO () syncRepo :: Options -> Logger -> RepoConfig -> IO ()
syncRepo opts logger repo = do syncRepo opts logger repo = do
logRepo logger repo "sync cycle starting" logRepoAt logger Debug repo "sync cycle starting"
-- Guard: bail out if an operation (merge, rebase, revert, etc.) is in progress -- Guard: bail out if an operation (merge, rebase, revert, etc.) is in progress
inProgress <- isInMiddleOfOperation (rcPath repo) inProgress <- isInMiddleOfOperation (rcPath repo)
when inProgress $ do when inProgress $ do
logRepo logger repo "*** SKIPPING: an operation (merge/rebase/revert/cherry-pick/bisect) is in progress ***" logRepoAt logger Warn repo "*** SKIPPING: an operation (merge/rebase/revert/cherry-pick/bisect) is in progress ***"
unless inProgress $ do unless inProgress $ do
-- Check what's changed -- Check what's changed
(_, statusOut, _) <- runGitIn (rcPath repo) ["status", "--porcelain"] (_, statusOut, _) <- runGitIn (rcPath repo) ["status", "--porcelain"]
let changed = filter (not . T.null) (T.lines statusOut) let changed = filter (not . T.null) (T.lines statusOut)
if null changed if null changed
then logRepo logger repo "no changes to commit" then logRepoAt logger Debug repo "no changes to commit"
else do else do
let count = length changed let count = length changed
let preview = T.unlines (take 15 changed) let preview = T.unlines (take 15 changed)
@@ -271,13 +372,15 @@ syncRepo opts logger repo = do
|| "nothing to commit" `T.isInfixOf` commitErr -> || "nothing to commit" `T.isInfixOf` commitErr ->
logRepo logger repo "nothing to commit (working tree clean)" logRepo logger repo "nothing to commit (working tree clean)"
| otherwise -> do | otherwise -> do
logRepo logger repo ("commit FAILED (exit " <> T.pack (show commitCode) <> "): " <> T.strip (commitOut <> commitErr)) logRepoAt logger Error repo ("commit FAILED (exit " <> T.pack (show commitCode) <> "): " <> T.strip (commitOut <> commitErr))
notifyUser notifyUser
opts opts
"Converge: Commit Failed" "Converge: Commit Failed"
("Commit failed in " <> rcPath repo <> " (exit " <> show commitCode <> ")") ("Commit failed in " <> rcPath repo <> " (exit " <> show commitCode <> ")")
-- Pull -- Pull
logRepo logger repo ("pulling " <> rcRemote repo <> "/" <> rcBranch repo) logRepo logger repo ("pulling " <> rcRemote repo <> "/" <> rcBranch repo)
(_, headBefore, _) <- runGitIn (rcPath repo) ["rev-parse", "HEAD"]
let headBefore' = T.strip headBefore
(pullCode, pullOut, pullErr) <- gitPull logger repo (pullCode, pullOut, pullErr) <- gitPull logger repo
case pullCode of case pullCode of
ExitSuccess -> do ExitSuccess -> do
@@ -285,8 +388,24 @@ syncRepo opts logger repo = do
if T.null trimmed if T.null trimmed
then logRepo logger repo "pull: already up to date" then logRepo logger repo "pull: already up to date"
else logRepo logger repo ("pull succeeded:\n" <> trimmed) else logRepo logger repo ("pull succeeded:\n" <> trimmed)
-- Notify on new commits pulled, if configured
when (rcNotifyOnPull repo) $ do
(_, headAfter, _) <- runGitIn (rcPath repo) ["rev-parse", "HEAD"]
let headAfter' = T.strip headAfter
when (headBefore' /= headAfter') $ do
let range = T.unpack headBefore' <> ".." <> T.unpack headAfter'
(_, logOut, _) <- runGitIn (rcPath repo) ["log", "--oneline", range]
let newCommits = filter (not . T.null) (T.lines logOut)
count = length newCommits
summary = T.unlines (take 5 newCommits)
more = if count > 5 then "\n... and " <> T.pack (show (count - 5)) <> " more" else ""
logRepo logger repo ("pulled " <> T.pack (show count) <> " new commit(s)")
notifyUser
opts
("Converge: " <> show count <> " New Commit(s) in " <> rcPath repo)
("Pulled " <> show count <> " new commit(s):\n" <> T.unpack summary <> T.unpack more)
_ -> do _ -> do
logRepo logger repo ("pull FAILED (exit " <> T.pack (show pullCode) <> "): " <> T.strip (pullOut <> pullErr)) logRepoAt logger Error repo ("pull FAILED (exit " <> T.pack (show pullCode) <> "): " <> T.strip (pullOut <> pullErr))
notifyUser notifyUser
opts opts
"Converge: Pull Failed" "Converge: Pull Failed"
@@ -295,7 +414,7 @@ syncRepo opts logger repo = do
conflicted <- hasConflicts (rcPath repo) conflicted <- hasConflicts (rcPath repo)
if conflicted if conflicted
then do then do
logRepo logger repo "*** MERGE CONFLICT detected! ***" logRepoAt logger Error repo "*** MERGE CONFLICT detected! ***"
notifyUser notifyUser
opts opts
"Converge: Merge Conflict" "Converge: Merge Conflict"
@@ -308,7 +427,7 @@ syncRepo opts logger repo = do
ExitSuccess -> ExitSuccess ->
logRepo logger repo "push succeeded" logRepo logger repo "push succeeded"
_ -> do _ -> do
logRepo logger repo ("push FAILED (exit " <> T.pack (show pushCode) <> "): " <> T.strip (pushOut <> pushErr)) logRepoAt logger Error repo ("push FAILED (exit " <> T.pack (show pushCode) <> "): " <> T.strip (pushOut <> pushErr))
notifyUser notifyUser
opts opts
"Converge: Push Failed" "Converge: Push Failed"
@@ -323,12 +442,14 @@ gitCommitAll :: Logger -> FilePath -> IO (ExitCode, Text, Text)
gitCommitAll logger repoPath = do gitCommitAll logger repoPath = do
addResult@(addCode, _, addErr) <- loggedGit logger repoPath ["add", "-A"] addResult@(addCode, _, addErr) <- loggedGit logger repoPath ["add", "-A"]
unless (T.null addErr) $ unless (T.null addErr) $
logInfo logger ("git add stderr: " <> addErr) logWarn logger ("git add stderr: " <> addErr)
if addCode /= ExitSuccess if addCode /= ExitSuccess
then pure addResult then pure addResult
else do else do
hostname <- init <$> readProcess "hostname" [] "" hostname <- init <$> readProcess "hostname" [] ""
loggedGit logger repoPath ["commit", "-m", "converge: auto-commit from " <> hostname] timestamp <- formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" <$> getCurrentTime
let msg = "converge: auto-commit at " <> timestamp <> " from " <> hostname
loggedGit logger repoPath ["commit", "-m", msg]
-- | Pull from the configured remote, rebasing onto the tracked upstream branch. -- | Pull from the configured remote, rebasing onto the tracked upstream branch.
gitPull :: Logger -> RepoConfig -> IO (ExitCode, Text, Text) gitPull :: Logger -> RepoConfig -> IO (ExitCode, Text, Text)
@@ -349,14 +470,17 @@ gitPush logger repo =
-- | Run a git command and log the invocation and exit code. -- | Run a git command and log the invocation and exit code.
loggedGit :: Logger -> FilePath -> [String] -> IO (ExitCode, Text, Text) loggedGit :: Logger -> FilePath -> [String] -> IO (ExitCode, Text, Text)
loggedGit logger repoPath args = do loggedGit logger repoPath args = do
logInfo logger ("RUN: git -C " <> T.pack repoPath <> " " <> T.intercalate " " (map T.pack args)) logDebug logger ("RUN: git -C " <> T.pack repoPath <> " " <> T.intercalate " " (map T.pack args))
result@(code, _, err) <- runGitIn repoPath args result@(code, _, err) <- runGitIn repoPath args
let msg = "EXIT " <> T.pack (show code) let msg = "EXIT " <> T.pack (show code)
details = details =
if code /= ExitSuccess && not (T.null err) if code /= ExitSuccess && not (T.null err)
then ": " <> T.strip err then ": " <> T.strip err
else "" else ""
logInfo logger (msg <> details) let exitLevel = case code of
ExitSuccess -> Debug
_ -> Error
logMsg logger exitLevel (msg <> details)
pure result pure result
-- | Check if the repository currently has merge conflicts. -- | Check if the repository currently has merge conflicts.
@@ -429,3 +553,137 @@ runGitIn :: FilePath -> [String] -> IO (ExitCode, Text, Text)
runGitIn repoPath args = do runGitIn repoPath args = do
(code, out, err) <- readProcessWithExitCode "git" ("-C" : repoPath : args) "" (code, out, err) <- readProcessWithExitCode "git" ("-C" : repoPath : args) ""
pure (code, T.pack out, T.pack err) pure (code, T.pack out, T.pack err)
----------------------------------------------------------------------
-- Relay listener (SSE client)
----------------------------------------------------------------------
-- | Push event received from a converge-relay SSE endpoint.
data PushEvent = PushEvent
{ peFullName :: !Text
-- ^ Gitea repository full_name (e.g. "myorg/myrepo").
, peBranch :: !Text
-- ^ Branch name.
, peRef :: !Text
-- ^ Full ref (e.g. "refs/heads/main").
}
deriving (Show, Eq)
instance FromJSON PushEvent where
parseJSON = withObject "PushEvent" $ \o ->
PushEvent
<$> o .: "full_name"
<*> o .: "branch"
<*> o .: "ref"
{- | Connect to a converge-relay SSE endpoint and trigger pulls on matching
repositories when push events arrive. Runs forever, reconnecting with
exponential backoff (1s .. 60s) on disconnection or error.
This is spawned as a background thread by 'runConverge' when an
@optRelayUrl@ is configured.
-}
connectRelay :: Text -> Logger -> [RepoConfig] -> IO ()
connectRelay url logger repos = do
logMsg logger Info ("relay: connecting to " <> url)
req <- parseRequest (T.unpack url)
manager <- newManager tlsManagerSettings{managerResponseTimeout = responseTimeoutNone}
let loop delay = do
let delaySec = min delay (60 :: Int)
result <- try $
withResponse req manager $ \resp ->
if responseStatus resp /= status200
then logMsg logger Error ("relay returned " <> T.pack (show (responseStatus resp)))
else do
logMsg logger Info "relay: connected, waiting for push events"
processSSE logger repos (responseBody resp)
case result of
Left (e :: SomeException) ->
logMsg logger Warn ("relay: connection error: " <> T.pack (show e) <> ", reconnecting in " <> T.pack (show delaySec) <> "s")
Right () ->
logMsg logger Info ("relay: connection closed, reconnecting in " <> T.pack (show delaySec) <> "s")
threadDelay (delaySec * 1000000)
loop (delaySec * 2)
loop 1
-- | Process an SSE stream from a 'BodyReader', dispatching push events to matching repos.
processSSE :: Logger -> [RepoConfig] -> BodyReader -> IO ()
processSSE logger repos reader = go BS.empty Nothing []
where
go buf mEvtType dataLines = do
chunk <- brRead reader
if BS.null chunk
then pure ()
else do
let (completeLines, leftover) = extractLines (buf <> chunk)
let (mEvtType', dataLines', events) = foldLines mEvtType dataLines completeLines
mapM_ (handleSSEEvent logger repos) events
go leftover mEvtType' dataLines'
-- | Split a 'BS.ByteString' into complete lines (stripping trailing \r) and leftover partial data.
extractLines :: BS.ByteString -> ([BS.ByteString], BS.ByteString)
extractLines bs = case BS.breakSubstring "\n" bs of
(line, rest)
| not (BS.null rest) ->
let line' = stripTrailingCr line
(lines', leftover) = extractLines (BS.drop 1 rest)
in (line' : lines', leftover)
_ -> ([], bs)
-- | Strip trailing \r from a line (for CRLF line endings).
stripTrailingCr :: BS.ByteString -> BS.ByteString
stripTrailingCr bs
| not (BS.null bs) && BS.last bs == cr = BS.init bs
| otherwise = bs
where
cr = 13 -- '\r'
-- | Fold a list of SSE lines into accumulated state and parsed events.
foldLines :: Maybe Text -> [BS.ByteString] -> [BS.ByteString] -> (Maybe Text, [BS.ByteString], [PushEvent])
foldLines evtType0 dLines0 sseLines =
let (evt, dl, evts) = foldl' step (evtType0, dLines0, []) sseLines
in (evt, dl, reverse evts)
where
step (evt, dl, events) line
| BS.null line =
case dl of
[] -> (Nothing, [], events)
_ -> case parseEvent evt dl of
Just evt' -> (Nothing, [], evt' : events)
Nothing -> (Nothing, [], events)
| Just rest <- pick "event: " line <|> pick "event:" line =
(Just (decodeUtf8 (BS.dropWhile (== space) rest)), dl, events)
| Just rest <- pick "data: " line <|> pick "data:" line =
(evt, dl ++ [BS.dropWhile (== space) rest], events)
| otherwise = (evt, dl, events)
pick = BS.stripPrefix
space = 32 -- ' '
-- | Parse accumulated SSE data lines into a 'PushEvent' (push events only).
parseEvent :: Maybe Text -> [BS.ByteString] -> Maybe PushEvent
parseEvent (Just "push") dataLines =
decode (BL.fromStrict (BS.intercalate "\n" dataLines))
parseEvent _ _ = Nothing
-- | Dispatch a parsed push event to matching repos (by 'rcGiteaRepo' and 'rcBranch').
handleSSEEvent :: Logger -> [RepoConfig] -> PushEvent -> IO ()
handleSSEEvent logger repos evt = do
let matching =
filter
( \r ->
rcGiteaRepo r == Just (peFullName evt)
&& rcBranch r == peBranch evt
)
repos
if null matching
then logMsg logger Debug ("relay: ignoring event for unconfigured repo: " <> peFullName evt <> " (branch: " <> peBranch evt <> ")")
else
mapM_
( \repo -> do
logRepo logger repo "relay: push event received, pulling"
inProgress <- isInMiddleOfOperation (rcPath repo)
if inProgress
then logRepoAt logger Warn repo "relay: skipping pull -- operation in progress"
else void $ gitPull logger repo
)
matching
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Converge - sync repos after waking from sleep
Documentation=https://github.com/jbrechtel/converge
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/converge --sync
# Small delay as a safety net for environments (e.g. netctl) where
# network-online.target may not gate properly on resume, giving the
# network stack time to reconnect before converge attempts to pull.
ExecStartPre=/bin/sleep 5
# Allow notify-send to reach the desktop session
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
[Install]
WantedBy=default.target
+1
View File
@@ -0,0 +1 @@
/work/personal/converge/converge.example.yaml
+1
View File
@@ -0,0 +1 @@
/work/personal/converge/converge.service
+307
View File
@@ -0,0 +1,307 @@
module RelaySpec (spec) where
import Converge (
PushEvent (..),
RepoConfig (..),
extractLines,
foldLines,
handleSSEEvent,
parseEvent,
)
import qualified Data.ByteString.Char8 as BSC
import qualified Data.Text as T
import System.FilePath ((</>))
import Test.Hspec
import TestHelper (commitCount, runGitIn, testLogger, withTempGitRepoWithRemote)
spec :: Spec
spec = do
extractLinesSpec
foldLinesSpec
parseEventSpec
handleSSEEventSpec
----------------------------------------------------------------------
-- extractLines
----------------------------------------------------------------------
extractLinesSpec :: Spec
extractLinesSpec = describe "extractLines" $ do
it "splits a single line" $ do
let (lines', leftover) = extractLines (bsc "\n")
lines' `shouldBe` [bsc ""]
leftover `shouldBe` bsc ""
it "splits multiple lines" $ do
let (lines', leftover) = extractLines (bsc "hello\nworld\n")
lines' `shouldBe` [bsc "hello", bsc "world"]
leftover `shouldBe` bsc ""
it "handles CRLF line endings" $ do
let (lines', leftover) = extractLines (bsc "hello\r\nworld\r\n")
lines' `shouldBe` [bsc "hello", bsc "world"]
leftover `shouldBe` bsc ""
it "returns partial line as leftover" $ do
let (lines', leftover) = extractLines (bsc "hello\nwor")
lines' `shouldBe` [bsc "hello"]
leftover `shouldBe` bsc "wor"
it "returns empty for empty input" $ do
let (lines', leftover) = extractLines (bsc "")
lines' `shouldBe` []
leftover `shouldBe` bsc ""
it "handles line with only CR" $ do
let (lines', leftover) = extractLines (bsc "\r\n")
lines' `shouldBe` [bsc ""]
leftover `shouldBe` bsc ""
----------------------------------------------------------------------
-- foldLines
----------------------------------------------------------------------
foldLinesSpec :: Spec
foldLinesSpec = describe "foldLines" $ do
it "parses a single push event" $ do
let lines' =
[ bsc "event: push"
, bsc "data: {\"full_name\":\"myorg/myrepo\",\"branch\":\"main\",\"ref\":\"refs/heads/main\"}"
, bsc ""
]
(finalEvtType, finalDLines, events) = foldLines Nothing [] lines'
events
`shouldBe` [ PushEvent
{ peFullName = "myorg/myrepo"
, peBranch = "main"
, peRef = "refs/heads/main"
}
]
finalEvtType `shouldBe` Nothing
finalDLines `shouldBe` []
it "parses two push events" $ do
let lines' =
[ bsc "event: push"
, bsc "data: {\"full_name\":\"a/b\",\"branch\":\"main\",\"ref\":\"refs/heads/main\"}"
, bsc ""
, bsc "event: push"
, bsc "data: {\"full_name\":\"c/d\",\"branch\":\"dev\",\"ref\":\"refs/heads/dev\"}"
, bsc ""
]
(_, _, events) = foldLines Nothing [] lines'
length events `shouldBe` 2
peFullName (events !! 0) `shouldBe` "a/b"
peFullName (events !! 1) `shouldBe` "c/d"
it "ignores non-push events" $ do
let lines' =
[ bsc "event: ping"
, bsc "data: hello"
, bsc ""
]
(_, _, events) = foldLines Nothing [] lines'
events `shouldBe` []
it "ignores comment lines (starting with colon)" $ do
let lines' =
[ bsc ": this is a comment"
, bsc "event: push"
, bsc "data: {\"full_name\":\"a/b\",\"branch\":\"main\",\"ref\":\"refs/heads/main\"}"
, bsc ""
]
(_, _, events) = foldLines Nothing [] lines'
length events `shouldBe` 1
it "handles event: without space after colon" $ do
let lines' =
[ bsc "event:push"
, bsc "data:{\"full_name\":\"a/b\",\"branch\":\"main\",\"ref\":\"refs/heads/main\"}"
, bsc ""
]
(_, _, events) = foldLines Nothing [] lines'
length events `shouldBe` 1
it "carries partial state across calls" $ do
-- First batch: incomplete event (missing blank line)
let batch1 = [bsc "event: push", bsc "data: {\"full_name\":\"a/b\",\"branch\":\"main\",\"ref\":\"refs/heads/main\"}"]
(evtType1, dLines1, events1) = foldLines Nothing [] batch1
events1 `shouldBe` []
evtType1 `shouldBe` Just "push"
dLines1 `shouldBe` [bsc "{\"full_name\":\"a/b\",\"branch\":\"main\",\"ref\":\"refs/heads/main\"}"]
-- Second batch: completes the event
let batch2 = [bsc ""]
(_, _, events2) = foldLines evtType1 dLines1 batch2
length events2 `shouldBe` 1
----------------------------------------------------------------------
-- parseEvent
----------------------------------------------------------------------
parseEventSpec :: Spec
parseEventSpec = describe "parseEvent" $ do
it "parses a valid push event" $ do
let evt = parseEvent (Just "push") [bsc "{\"full_name\":\"org/repo\",\"branch\":\"main\",\"ref\":\"refs/heads/main\"}"]
evt
`shouldBe` Just
PushEvent
{ peFullName = "org/repo"
, peBranch = "main"
, peRef = "refs/heads/main"
}
it "returns Nothing for non-push event type" $ do
let evt = parseEvent (Just "ping") [bsc "{}"]
evt `shouldBe` Nothing
it "returns Nothing for malformed JSON" $ do
let evt = parseEvent (Just "push") [bsc "not json"]
evt `shouldBe` Nothing
it "handles multiple data lines joined" $ do
let evt =
parseEvent
(Just "push")
[ bsc "{\"full_name\":\"x\""
, bsc ",\"branch\":\"y\""
, bsc ",\"ref\":\"z\"}"
]
evt
`shouldBe` Just
PushEvent
{ peFullName = "x"
, peBranch = "y"
, peRef = "z"
}
----------------------------------------------------------------------
-- handleSSEEvent (integration — uses real git repos)
----------------------------------------------------------------------
handleSSEEventSpec :: Spec
handleSSEEventSpec = describe "handleSSEEvent" $ do
it "pulls on a matching repo when a push event arrives" $
withTempGitRepoWithRemote $ \localRepo -> do
logger <- testLogger
-- Simulate someone else pushing: commit locally, push, then reset local behind
writeFile (localRepo </> "remote-change.txt") "pushed from elsewhere"
_ <- runGitIn localRepo ["add", "remote-change.txt"]
_ <- runGitIn localRepo ["commit", "-m", "simulated remote push"]
_ <- runGitIn localRepo ["push", "origin", "main"]
-- Verify remote has the commit
(_, remoteLog, _) <- runGitIn localRepo ["log", "--oneline", "origin/main"]
let remoteCount = length (filter (not . T.null) (T.lines remoteLog))
-- Reset local repo to one commit behind
_ <- runGitIn localRepo ["reset", "--hard", "HEAD~1"]
-- Verify local is behind
localBefore <- commitCount localRepo
localBefore `shouldBe` (remoteCount - 1)
-- Create a repo config with matching gitea_repo
let repoCfg =
(defaultRepoCfg localRepo)
{ rcGiteaRepo = Just "test-org/test-repo"
}
-- Send a matching push event
let evt =
PushEvent
{ peFullName = "test-org/test-repo"
, peBranch = "main"
, peRef = "refs/heads/main"
}
handleSSEEvent logger [repoCfg] evt
-- Local should now have the remote's commit
localAfter <- commitCount localRepo
localAfter `shouldBe` remoteCount
it "ignores events for repos with non-matching gitea_repo" $
withTempGitRepoWithRemote $ \localRepo -> do
logger <- testLogger
let repoCfg =
(defaultRepoCfg localRepo)
{ rcGiteaRepo = Just "test-org/test-repo"
}
let evt =
PushEvent
{ peFullName = "other-org/other-repo"
, peBranch = "main"
, peRef = "refs/heads/main"
}
-- This should not crash or pull — it just ignores
handleSSEEvent logger [repoCfg] evt
-- Test passes if no exception is thrown
it "ignores events for repos with non-matching branch" $
withTempGitRepoWithRemote $ \localRepo -> do
logger <- testLogger
let repoCfg =
(defaultRepoCfg localRepo)
{ rcGiteaRepo = Just "test-org/test-repo"
, rcBranch = "main"
}
let evt =
PushEvent
{ peFullName = "test-org/test-repo"
, peBranch = "dev"
, peRef = "refs/heads/dev"
}
commitsBefore <- commitCount localRepo
handleSSEEvent logger [repoCfg] evt
commitsAfter <- commitCount localRepo
commitsAfter `shouldBe` commitsBefore -- no pull should have happened
it "skips pull when an operation is in progress" $
withTempGitRepoWithRemote $ \localRepo -> do
logger <- testLogger
-- Create a MERGE_HEAD sentinel to simulate a merge in progress
(_, gitDirOut, _) <- runGitIn localRepo ["rev-parse", "--git-dir"]
let sentinelFile = localRepo </> T.unpack (T.strip gitDirOut) </> "MERGE_HEAD"
writeFile sentinelFile "fake merge head"
-- Push a new commit to remote
writeFile (localRepo </> "during-merge.txt") "change during merge"
_ <- runGitIn localRepo ["add", "during-merge.txt"]
_ <- runGitIn localRepo ["commit", "-m", "commit during merge"]
_ <- runGitIn localRepo ["push", "origin", "main"]
_ <- runGitIn localRepo ["reset", "--hard", "HEAD~1"]
commitsBefore <- commitCount localRepo
let repoCfg =
(defaultRepoCfg localRepo)
{ rcGiteaRepo = Just "test-org/test-repo"
}
let evt =
PushEvent
{ peFullName = "test-org/test-repo"
, peBranch = "main"
, peRef = "refs/heads/main"
}
handleSSEEvent logger [repoCfg] evt
commitsAfter <- commitCount localRepo
commitsAfter `shouldBe` commitsBefore -- pull should be skipped
----------------------------------------------------------------------
-- Helpers
----------------------------------------------------------------------
-- | Shortcut for ByteString from ASCII.
bsc :: String -> BSC.ByteString
bsc = BSC.pack
-- | Create a default RepoConfig for a given path (for testing).
defaultRepoCfg :: FilePath -> RepoConfig
defaultRepoCfg path =
RepoConfig
{ rcPath = path
, rcRemote = "origin"
, rcBranch = "main"
, rcNotifyOnPull = False
, rcGiteaRepo = Nothing
}
+2
View File
@@ -3,6 +3,7 @@ module Main (main) where
import qualified GitCommitSpec import qualified GitCommitSpec
import qualified GitPullSpec import qualified GitPullSpec
import qualified GitSafetySpec import qualified GitSafetySpec
import qualified RelaySpec
import Test.Hspec import Test.Hspec
main :: IO () main :: IO ()
@@ -10,3 +11,4 @@ main = hspec $ do
GitCommitSpec.spec GitCommitSpec.spec
GitPullSpec.spec GitPullSpec.spec
GitSafetySpec.spec GitSafetySpec.spec
RelaySpec.spec
+4 -3
View File
@@ -16,12 +16,13 @@ module TestHelper (
) where ) where
import Control.Concurrent.MVar (newMVar) import Control.Concurrent.MVar (newMVar)
import Converge (Logger (..)) import Converge (LogLevel (..), Logger (..))
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import System.Directory (createDirectory) import System.Directory (createDirectory)
import System.Exit (ExitCode) import System.Exit (ExitCode)
import System.FilePath ((</>)) import System.FilePath ((</>))
import System.IO (stderr)
import System.IO.Temp (withSystemTempDirectory) import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode) import System.Process (readProcessWithExitCode)
@@ -37,9 +38,9 @@ rawGit args = do
(code, out, err) <- readProcessWithExitCode "git" args "" (code, out, err) <- readProcessWithExitCode "git" args ""
pure (code, T.pack out, T.pack err) pure (code, T.pack out, T.pack err)
-- | Create a test logger (serialized writes to stderr). -- | Create a test logger (serialized writes to stderr, Debug level).
testLogger :: IO Logger testLogger :: IO Logger
testLogger = Logger <$> newMVar () testLogger = Logger <$> newMVar () <*> pure Debug <*> pure stderr
{- | Create a temporary git repository, configure a user, make an initial {- | Create a temporary git repository, configure a user, make an initial
commit on branch "main", run the action with the repo path, then clean up. commit on branch "main", run the action with the repo path, then clean up.