Compare commits

..

44 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
jbrechtel d22ea39cde Formats repo
Build / build (push) Failing after 11s
2026-05-13 23:02:04 -04:00
jbrechtel 25b8f24906 Fix two git operation bugs
- gitCommitAll: abort commit when staging (git add -A) fails, e.g.
  due to a stale .git/index.lock.  Previously the failure was ignored
  and commit proceeded on an empty or partial index.

- gitPull: remove explicit branch argument from git pull --rebase.
  Passing both remote and branch can conflict with the branch's
  configured tracking ref, causing 'Cannot rebase onto multiple
  branches'.  Now uses just --rebase <remote>, letting git resolve
  the tracking branch from config.

- Test: index.lock scenario verifies commit is aborted on staging
  failure.
2026-05-13 22:51:09 -04:00
jbrechtel 7d2a4b3a30 Mark all TODO items as done 2026-05-13 22:20:01 -04:00
jbrechtel 2f2cc16551 Log all git write operations with command, args, and exit code
- Add loggedGit helper that logs 'RUN: git -C <path> <args>' before
  execution and 'EXIT <code>' after (with stderr on failure)
- Refactor gitCommitAll, gitPull, and gitPush to use loggedGit
- Add Logger parameter to gitPull and gitPush
- Update test call sites and syncRepo to pass Logger
2026-05-13 22:19:44 -04:00
jbrechtel e0f986a5ee Formats repo 2026-05-13 22:15:55 -04:00
jbrechtel 63e034fd01 Commit and push uncommitted changes on startup
- watchRepo: call syncRepo at startup to commit any pre-existing
  uncommitted changes before entering the file watcher
- Add gitPush function for git push <remote> <branch>
- Add push step to syncRepo after successful pull (with failure
  notification via notifyUser)
- Export syncRepo and gitPush for testing
- Test: startup sync commits and pushes uncommitted changes
2026-05-13 22:10:30 -04:00
jbrechtel eb847c03f6 Notify on all git operation failures, not just merge conflicts
Generalize notifyConflict into notifyUser that accepts an arbitrary
title and body.  Call notify-send for commit failures and pull
failures in addition to the existing merge-conflict notification.
"Nothing to commit" is intentionally excluded — it is an expected
no-op, not a failure.
2026-05-13 22:05:26 -04:00
jbrechtel bcf43d5d83 updating documentation 2026-05-13 22:01:43 -04:00
jbrechtel ee8a4cb449 Add AGENTS.md — project guide for AI coding agents 2026-05-13 21:50:28 -04:00
jbrechtel 4bc23b5d7c Use --rebase instead of --no-edit in gitPull
git pull --no-edit fails with exit 128 on divergent branches because
fast-forward is impossible.  Using --rebase handles divergent histories
by rebasing local commits on top of fetched remote commits, producing
a linear history without merge commits.

- src/Converge.hs: Change gitPull from --no-edit to --rebase
- test/GitPullSpec.hs: Update rebase test to expect success and
  verify linear history (3 commits, no merge parents)
- test/*.hs: Clean up unused imports
2026-05-13 21:48:49 -04:00
jbrechtel 65bf75cdf2 Adds specs 2026-05-13 21:39:02 -04:00
jbrechtel 34c8232427 Avoids running commands during external git operations 2026-05-13 21:00:09 -04:00
36 changed files with 2393 additions and 127 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/
+176
View File
@@ -0,0 +1,176 @@
# AGENTS.md — Project guide for AI coding agents
## Project overview
**Converge** watches git repositories for file changes, automatically commits them, and pulls from a remote via rebase. It notifies the user via `notify-send` on merge conflicts.
- **Language**: Haskell (GHC 9.10.3, Stackage lts-24.38)
- **Build system**: Stack (config in `stack.yaml`, packages in `package.yaml`)
- **Cabal file**: `converge.cabal` is generated from `package.yaml` via hpack
## File layout
```
src/Converge.hs # Single library module — all core logic
app/Main.hs # CLI entry point (optparse-applicative)
test/Spec.hs # Test runner (imports and runs all spec modules)
test/TestHelper.hs # Shared test utilities (temp repos, sentinel files)
test/GitCommitSpec.hs # Commit behaviour tests
test/GitPullSpec.hs # Pull/rebase tests
test/GitSafetySpec.hs # Operation-in-progress detection tests
SPEC.md # Human-readable specifications
converge.example.yaml # Example config file
converge.service # systemd user service (daemon mode)
```
## Architecture
All core logic lives in the single `Converge` module (`src/Converge.hs`). Key sections:
- **Types**: `Options`, `RepoConfig`, `Config`, `Logger` (an `MVar ()` for serialized logging)
- **Config**: YAML config file parsing via `Data.Yaml`, XDG path discovery
- **File watching**: `fsnotify` via `watchRepo` — one per configured repo, each in its own thread (via `async`)
- **Debounce**: An `MVar` pattern coalesces rapid events into one sync per quiet period
- **Sync cycle** (`syncRepo`): guarded by `isInMiddleOfOperation`, then stage + commit + pull + conflict check
- **Git operations**: shell out to `git` via `System.Process.readProcessWithExitCode` — no libgit2 dependency
- **Notifications**: shell out to `notify-send` via `System.Process.spawnProcess`
- **Relay listener**: `connectRelay` connects to a converge-relay SSE endpoint, parses push events, and triggers pulls on matching repos (by `gitea_repo` + branch). Runs as a background thread with exponential backoff reconnection.
### Relay architecture
```
┌─────────────┐ POST /webhook ┌──────────────────┐ GET /events (SSE) ┌──────────────────┐
│ Gitea │ ────────────────> │ converge-relay │ ───────────────────> │ converge (local) │
│ Server │ (push event) │ (Go, hosted) │ event: push │ (Haskell) │
└─────────────┘ └──────────────────┘ └─────────┬─────────┘
matches gitea_repo
→ git pull
```
The Go relay is a zero-dependency pub/sub server. Key design:
- `POST /webhook` — receives Gitea push events, optional HMAC-SHA256 verification
- `GET /events` — SSE stream, clients connect and hold the connection open
- Channel-based broker: `Subscribe()` → channel, `Unsubscribe(ch)`, `Publish(event)` — non-blocking send to all subscribers
- Slow subscribers are silently dropped (channel buffer = 16)
### Git operations
| Function | Git command |
|---|---|
| `gitCommitAll` | `git add -A` + `git commit -m "converge: auto-commit from <host>"` |
| `gitPull` | `git pull --rebase <remote> <branch>` |
| `hasConflicts` | `git diff --check` looking for `<<<<<<<` / `=======` / `>>>>>>>` |
| `isInMiddleOfOperation` | `git rev-parse --git-dir` then checks sentinel files/dirs |
### Operation detection sentinels
`checkIndicatorFiles` checks these inside `.git/`:
| File/Dir | Operation |
|---|---|
| `MERGE_HEAD` | Merge in progress |
| `REVERT_HEAD` | Revert in progress |
| `CHERRY_PICK_HEAD` | Cherry-pick in progress |
| `BISECT_LOG` | Bisect in progress |
| `rebase-apply/` | Rebase in progress |
| `rebase-merge/` | Rebase in progress |
| `sequencer/` | Sequencer-based operation in progress |
`REBASE_HEAD` is intentionally excluded — git does not remove it when a rebase completes, causing false positives.
## Build / test / run
### Haskell (converge)
```bash
stack build # Build library + executable + tests
stack test # Build and run the test suite
stack exec converge -- [options] # Run the tool directly
./scripts/build # Format + build + compress (uses Docker via ./hs)
./scripts/test # Run tests (uses Docker via ./hs)
./scripts/run # Run the tool (uses Docker via ./hs)
```
### Go (converge-relay)
```bash
cd converge-relay
./scripts/build # Format + test + build (uses Docker via ./goctl)
./scripts/test # Run tests (uses Docker via ./goctl)
./scripts/run --listen :8080 # Run the relay (uses Docker via ./goctl)
```
## Test conventions
Tests use **hspec** and create real temporary git repositories — no mocking of git commands. This avoids complexity and tests actual git semantics.
### Helper module (`test/TestHelper.hs`)
- `withTempGitRepo :: (FilePath -> IO a) -> IO a` — creates a temp dir, runs `git init -b main`, configures user, makes an initial commit, runs the action, cleans up.
- `withTempGitRepoWithRemote :: (FilePath -> IO a) -> IO a` — like above but also creates a bare remote, adds it as `origin`, and pushes the initial commit.
- `runGitIn :: FilePath -> [String] -> IO (ExitCode, Text, Text)` — runs `git -C <path> <args>`.
- `rawGit :: [String] -> IO (ExitCode, Text, Text)` — runs `git <args>` without `-C` (for `git clone`, etc.).
- `testLogger :: IO Logger` — creates a test Logger backed by a fresh `MVar`.
- Sentinel helpers (`createMergeHead`, `createRebaseApply`, etc.) — create the known sentinel files/dirs inside `.git/`.
### Writing new tests
1. Create a new spec module in `test/` (e.g., `test/MyNewSpec.hs`)
2. Export a top-level `spec :: Spec` value
3. Import it in `test/Spec.hs` and add it to the `hspec` block
4. Use `withTempGitRepo` for tests that need a git repo
5. If the test needs new exports from `Converge`, add them to the module's export list
### Spec coverage
Tests are organized to match `SPEC.md`:
Anytime new behavior is added the `SPEC.md` should be updated and a corresponding hspec test written.
| Spec | Test module | Key tests |
|---|---|---|
| Creates commit on file change | `GitCommitSpec` | Commit creation, content inclusion, no duplicate commits |
| One commit per debounce window | `GitCommitSpec` | Idempotency of `gitCommitAll` |
| Pulls from remote origin | `GitPullSpec` | Fast-forward pull scenario |
| Rebases on pull | `GitPullSpec` | Divergent history → linear history, no merge commits |
| No-op during operations | `GitSafetySpec` | All 7 sentinel types + clean repo |
| `RelaySpec` | SSE line parsing, event folding, JSON parsing, repo matching, operation-in-progress skip |
## Key conventions
- **Default extensions** (from `package.yaml`): `OverloadedStrings`, `RecordWildCards`, `ScopedTypeVariables`, `TupleSections`
- **GHC options**: `-Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints`
- **Strings are `Text`** (via `OverloadedStrings`), not `String`
- **Logging** goes through the `Logger` newtype wrapping `MVar ()` — all logging is serialized to stderr
- **Hostname** for commit messages is obtained via `readProcess "hostname" [] ""`
- **Git commands** always use `readProcessWithExitCode` (not `readProcess` except for hostname)
## Dependencies
### Haskell
| Package | Purpose |
|---|---|
| `async` | Concurrent repo watching |
| `fsnotify` | File system event monitoring |
| `optparse-applicative` | CLI argument parsing |
| `yaml` | Config file parsing |
| `http-client` | HTTP client for SSE relay connection |
| `http-client-tls` | TLS support for HTTPS relay endpoints |
| `http-types` | HTTP status codes |
| `aeson` | JSON parsing (push events from relay) |
| `bytestring` | Efficient binary data for SSE stream parsing |
| `temporary` (test only) | Temp directories for test repos |
| `hspec` (test only) | Test framework |
### Go (converge-relay)
Zero external dependencies — stdlib only (`net/http`, `log/slog`, `crypto/hmac`, `encoding/json`, `sync`).
## External requirements
- `git` must be on PATH (no libgit2 dependency)
- `notify-send` for desktop notifications (optional — only called on conflict/pull)
- `hostname` command for commit message generation
- For realtime pull: a running `converge-relay` instance reachable via HTTP(S)
+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"
}
+25
View File
@@ -0,0 +1,25 @@
# Converge specifications
## Creates a commit after a file is changed
## Only creates one commit when multiple changes occur within a 5 second window
## Pulls changes from remote "origin" before pushing commits
## Rebases when pulling changes from "origin"
## Does not operate (stage, commit or push) on git repos when:
### a rebase is in progess
### a merge is in progess
### a revert is in progess
### a cherry-pick 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
+17
View File
@@ -0,0 +1,17 @@
# ~~Expand desktop notifications~~
~~Converge should use notify-send anytime there is a failure with a git operation~~
✅ Done (notify-send on commit, pull, and push failures — eb847c0)
# ~~Commit and push uncommitted changes~~
~~On startup, Converge should detect any uncommitted changes in a monitored repo and then commit and push them.~~
✅ Done (startup sync + push — 63e034f)
# ~~Git write operations should be logged~~
~~All git operations that modify repo state should be logged - including the time, command, arguments and exit code. Possibly the standard out and standard error.~~
✅ Done (loggedGit helper — 2f2cc16)
+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
+10 -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
@@ -65,3 +70,4 @@ tests:
dependencies: dependencies:
- converge - converge
- hspec - hspec
- temporary
+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 "$@"
+426 -48
View File
@@ -2,6 +2,7 @@ module Converge (
-- * Core types -- * Core types
Options (..), Options (..),
RepoConfig (..), RepoConfig (..),
LogLevel (..),
defaultOptions, defaultOptions,
defaultRepoConfig, defaultRepoConfig,
@@ -14,33 +15,61 @@ module Converge (
-- * Running -- * Running
runConverge, runConverge,
syncRepo,
-- * Logger
Logger (..),
newLogger,
-- * Git operations -- * Git operations
gitCommitAll, gitCommitAll,
gitPull, gitPull,
gitPush,
runGitIn,
hasConflicts, hasConflicts,
isInMiddleOfOperation,
checkIndicatorFiles,
-- * Notifications -- * Notifications
notifyConflict, notifyUser,
-- * File filtering
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) 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 System.Directory (XdgDirectory (XdgConfig), doesFileExist, getCurrentDirectory, getXdgDirectory) 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.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.
@@ -51,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]
@@ -62,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)
@@ -72,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.
@@ -81,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)
@@ -100,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)
@@ -108,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 ->
@@ -115,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
@@ -126,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@.
@@ -140,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
@@ -158,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.
@@ -171,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
@@ -180,11 +293,15 @@ watchRepo opts logger repo = do
-- Drain any events that arrived during the debounce window -- Drain any events that arrived during the debounce window
void $ tryTakeMVar pending void $ tryTakeMVar pending
syncRepo opts logger repo syncRepo opts logger repo
-- Sync any uncommitted changes that exist on startup
syncRepo opts logger repo
bracket bracket
(startManagerConf defaultConfig) (startManagerConf defaultConfig)
stopManager stopManager
$ \mgr -> do $ \mgr -> do
_ <- watchTree mgr absPath (\e -> not (isGitPath (eventPath e))) $ \event -> do _ <- watchTree mgr absPath (\e -> not (isGitPath (eventPath e))) $ \event -> do
ignored <- isGitIgnored (rcPath repo) (eventPath event)
unless ignored $ do
logEvent logger repo event logEvent logger repo event
void $ tryPutMVar pending () void $ tryPutMVar pending ()
forever $ threadDelay maxBound forever $ threadDelay maxBound
@@ -215,15 +332,32 @@ eventLabel event = case event of
isGitPath :: FilePath -> Bool isGitPath :: FilePath -> Bool
isGitPath = elem ".git" . splitDirectories isGitPath = elem ".git" . splitDirectories
-- | Run a full sync cycle on a single repo: commit + pull + conflict check. {- | Check whether a file path is ignored by the repository's gitignore rules.
Uses @git check-ignore -q@ which respects all gitignore sources
(.gitignore, .git/info/exclude, global gitignore, etc.).
-}
isGitIgnored :: FilePath -> FilePath -> IO Bool
isGitIgnored repoPath filePath = do
(code, _, _) <- readProcessWithExitCode "git" ["-C", repoPath, "check-ignore", "-q", filePath] ""
pure (code == ExitSuccess)
{- | Run a full sync cycle on a single repo: commit + pull + conflict check.
Skips the cycle if the repo is in the middle of an operation such as a
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
inProgress <- isInMiddleOfOperation (rcPath repo)
when inProgress $ do
logRepoAt logger Warn repo "*** SKIPPING: an operation (merge/rebase/revert/cherry-pick/bisect) is in progress ***"
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)
@@ -237,26 +371,67 @@ syncRepo opts logger repo = do
| "nothing to commit" `T.isInfixOf` commitOut | "nothing to commit" `T.isInfixOf` commitOut
|| "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 -> | 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
opts
"Converge: Commit Failed"
("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)
(pullCode, pullOut, pullErr) <- gitPull repo (_, headBefore, _) <- runGitIn (rcPath repo) ["rev-parse", "HEAD"]
let headBefore' = T.strip headBefore
(pullCode, pullOut, pullErr) <- gitPull logger repo
case pullCode of case pullCode of
ExitSuccess -> do ExitSuccess -> do
let trimmed = T.strip pullOut let trimmed = T.strip pullOut
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
logRepo logger repo ("pull FAILED (exit " <> T.pack (show pullCode) <> "): " <> T.strip (pullOut <> pullErr)) 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
logRepoAt logger Error repo ("pull FAILED (exit " <> T.pack (show pullCode) <> "): " <> T.strip (pullOut <> pullErr))
notifyUser
opts
"Converge: Pull Failed"
("Pull failed in " <> rcPath repo <> " (exit " <> show pullCode <> ")")
-- Conflict check -- Conflict check
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! ***"
notifyConflict opts repo notifyUser
else logRepo logger repo "sync complete, no conflicts" opts
"Converge: Merge Conflict"
("A merge conflict occurred in " <> rcPath repo <> ". Manual resolution required.")
else do
logRepo logger repo "sync complete, no conflicts"
-- Push local commits to remote
(pushCode, pushOut, pushErr) <- gitPush logger repo
case pushCode of
ExitSuccess ->
logRepo logger repo "push succeeded"
_ -> do
logRepoAt logger Error repo ("push FAILED (exit " <> T.pack (show pushCode) <> "): " <> T.strip (pushOut <> pushErr))
notifyUser
opts
"Converge: Push Failed"
("Push failed in " <> rcPath repo <> " (exit " <> show pushCode <> ")")
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Git operations -- Git operations
@@ -265,18 +440,48 @@ syncRepo opts logger repo = do
-- | Stage all changes and commit with an auto-generated message. -- | Stage all changes and commit with an auto-generated message.
gitCommitAll :: Logger -> FilePath -> IO (ExitCode, Text, Text) gitCommitAll :: Logger -> FilePath -> IO (ExitCode, Text, Text)
gitCommitAll logger repoPath = do gitCommitAll logger repoPath = do
(_, _, stageErr) <- runGitIn repoPath ["add", "-A"] addResult@(addCode, _, addErr) <- loggedGit logger repoPath ["add", "-A"]
unless (T.null stageErr) $ unless (T.null addErr) $
logInfo logger ("git add stderr: " <> stageErr) logWarn logger ("git add stderr: " <> addErr)
if addCode /= ExitSuccess
then pure addResult
else do
hostname <- init <$> readProcess "hostname" [] "" hostname <- init <$> readProcess "hostname" [] ""
runGitIn 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 and branch. -- | Pull from the configured remote, rebasing onto the tracked upstream branch.
gitPull :: RepoConfig -> IO (ExitCode, Text, Text) gitPull :: Logger -> RepoConfig -> IO (ExitCode, Text, Text)
gitPull repo = gitPull logger repo =
runGitIn loggedGit
logger
(rcPath repo) (rcPath repo)
["pull", "--no-edit", T.unpack (rcRemote repo), T.unpack (rcBranch repo)] ["pull", "--rebase", T.unpack (rcRemote repo)]
-- | Push to the configured remote and branch.
gitPush :: Logger -> RepoConfig -> IO (ExitCode, Text, Text)
gitPush logger repo =
loggedGit
logger
(rcPath repo)
["push", T.unpack (rcRemote repo), T.unpack (rcBranch repo)]
-- | Run a git command and log the invocation and exit code.
loggedGit :: Logger -> FilePath -> [String] -> IO (ExitCode, Text, Text)
loggedGit logger repoPath args = do
logDebug logger ("RUN: git -C " <> T.pack repoPath <> " " <> T.intercalate " " (map T.pack args))
result@(code, _, err) <- runGitIn repoPath args
let msg = "EXIT " <> T.pack (show code)
details =
if code /= ExitSuccess && not (T.null err)
then ": " <> T.strip err
else ""
let exitLevel = case code of
ExitSuccess -> Debug
_ -> Error
logMsg logger exitLevel (msg <> details)
pure result
-- | Check if the repository currently has merge conflicts. -- | Check if the repository currently has merge conflicts.
hasConflicts :: FilePath -> IO Bool hasConflicts :: FilePath -> IO Bool
@@ -284,20 +489,59 @@ hasConflicts repoPath = do
(_, diffOut, _) <- runGitIn repoPath ["diff", "--check"] (_, diffOut, _) <- runGitIn repoPath ["diff", "--check"]
pure $ any (`T.isInfixOf` diffOut) ["<<<<<<<", "=======", ">>>>>>>"] pure $ any (`T.isInfixOf` diffOut) ["<<<<<<<", "=======", ">>>>>>>"]
{- | Check whether a repository is in the middle of an operation that would
make it unsafe to commit or pull: merge, rebase, revert, cherry-pick,
or bisect.
Detects these by checking for well-known sentinel files and directories
inside the repository's git directory (discovered via @git rev-parse --git-dir@).
-}
isInMiddleOfOperation :: FilePath -> IO Bool
isInMiddleOfOperation repoPath = do
(code, gitDirOut, _) <- runGitIn repoPath ["rev-parse", "--git-dir"]
case code of
ExitSuccess -> do
let gitDir = T.strip gitDirOut
-- Use the relative git-dir path inside the repo
let fullGitDir = repoPath </> T.unpack gitDir
checkIndicatorFiles fullGitDir
_ ->
-- Not a git repo at all; treat as "no operation"
pure False
{- | Check for sentinel files/directories that indicate an in-progress operation.
Note: @REBASE_HEAD@ is intentionally excluded from the sentinel files.
Git does not remove it when a rebase completes, so it produces false
positives. The reliable rebase indicators are the @rebase-apply@ and
@rebase-merge@ directories, which are checked below.
-}
checkIndicatorFiles :: FilePath -> IO Bool
checkIndicatorFiles gitDir = do
let sentinelFiles =
[ "MERGE_HEAD"
, "REVERT_HEAD"
, "CHERRY_PICK_HEAD"
, "BISECT_LOG"
]
let sentinelDirs =
[ "rebase-apply"
, "rebase-merge"
, "sequencer"
]
fileHits <- mapM (\f -> doesFileExist (gitDir </> f)) sentinelFiles
dirHits <- mapM (\d -> doesDirectoryExist (gitDir </> d)) sentinelDirs
pure $ or fileHits || or dirHits
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- Notifications -- Notifications
---------------------------------------------------------------------- ----------------------------------------------------------------------
-- | Send a desktop notification about a merge conflict. -- | Send a desktop notification.
notifyConflict :: Options -> RepoConfig -> IO () notifyUser :: Options -> String -> String -> IO ()
notifyConflict opts repo = do notifyUser opts title body = do
let cmd = T.unpack (optNotifyCmd opts) let cmd = T.unpack (optNotifyCmd opts)
title = "Converge: Merge Conflict" _ <- spawnProcess cmd ["--urgency=critical", title, body]
body =
"A merge conflict occurred in "
<> T.pack (rcPath repo)
<> ". Manual resolution required."
_ <- spawnProcess cmd ["--urgency=critical", title, T.unpack body]
pure () pure ()
---------------------------------------------------------------------- ----------------------------------------------------------------------
@@ -309,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
+93
View File
@@ -0,0 +1,93 @@
module GitCommitSpec (spec) where
import Converge (Options (..), RepoConfig (..), defaultOptions, defaultRepoConfig, gitCommitAll, syncRepo)
import qualified Data.Text as T
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import Test.Hspec
import TestHelper
spec :: Spec
spec = do
describe "gitCommitAll" $ do
it "creates a commit after a file is changed" $ do
logger <- testLogger
withTempGitRepo $ \repo -> do
countBefore <- commitCount repo
countBefore `shouldBe` 1 -- initial commit from helper
writeFile (repo </> "hello.txt") "hello world"
(code, _, _) <- gitCommitAll logger repo
code `shouldBe` ExitSuccess
countAfter <- commitCount repo
countAfter `shouldBe` (countBefore + 1)
it "includes the changed file in the commit" $ do
logger <- testLogger
withTempGitRepo $ \repo -> do
writeFile (repo </> "data.txt") "some data"
(code, _, _) <- gitCommitAll logger repo
code `shouldBe` ExitSuccess
(_, logOut, _) <- runGitIn repo ["log", "-1", "--name-only", "--oneline"]
logOut `shouldSatisfy` ("data.txt" `T.isInfixOf`)
it "does not create a new commit when there are no changes" $ do
logger <- testLogger
withTempGitRepo $ \repo -> do
countBefore <- commitCount repo
(code, _, _) <- gitCommitAll logger repo
-- git commit exits non-zero when there is nothing to commit
code `shouldSatisfy` (/= ExitSuccess)
countAfter <- commitCount repo
countAfter `shouldBe` countBefore
it "creates only one commit when called multiple times with the same staged changes" $ do
logger <- testLogger
withTempGitRepo $ \repo -> do
writeFile (repo </> "single.txt") "single change"
(code1, _, _) <- gitCommitAll logger repo
code1 `shouldBe` ExitSuccess
-- Second call: nothing left to commit
countBefore2 <- commitCount repo
(code2, _, _) <- gitCommitAll logger repo
code2 `shouldSatisfy` (/= ExitSuccess)
countAfter2 <- commitCount repo
countAfter2 `shouldBe` countBefore2
it "aborts commit when staging fails (e.g. index.lock exists)" $ do
logger <- testLogger
withTempGitRepo $ \repo -> do
-- Create a lock file to make git add -A fail
writeFile (repo </> ".git" </> "index.lock") "locked"
writeFile (repo </> "new.txt") "new file"
countBefore <- commitCount repo
(code, _, err) <- gitCommitAll logger repo
-- Staging fails, so commit should not happen
code `shouldSatisfy` (/= ExitSuccess)
err `shouldSatisfy` ("index.lock" `T.isInfixOf`)
commitCount repo `shouldReturn` countBefore
describe "startup sync" $ do
it "commits and pushes uncommitted changes on startup" $ do
logger <- testLogger
withTempGitRepoWithRemote $ \repo -> do
-- Leave a file uncommitted (simulating state before converge starts)
writeFile (repo </> "uncommitted.txt") "pending change"
countBefore <- commitCount repo
countBefore `shouldBe` 1
let cfg = defaultRepoConfig{rcPath = repo}
let opts =
defaultOptions
{ optRepos = [cfg]
, optNotifyCmd = "true" -- avoid firing notify-send
}
syncRepo opts logger cfg
countAfter <- commitCount repo
countAfter `shouldBe` 2
-- Verify the commit was pushed to remote
(_, remoteLog, _) <- runGitIn repo ["log", "origin/main", "--oneline", "--name-only"]
remoteLog `shouldSatisfy` ("uncommitted.txt" `T.isInfixOf`)
+86
View File
@@ -0,0 +1,86 @@
module GitPullSpec (spec) where
import Converge (RepoConfig (..), defaultRepoConfig, gitPull)
import qualified Data.Text as T
import System.Directory (createDirectory)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import TestHelper
spec :: Spec
spec = do
describe "gitPull" $ do
it "pulls changes from remote 'origin'" $ do
logger <- testLogger
withTempGitRepoWithRemote $ \local -> do
-- Make a second commit on local and push to remote
writeFile (local </> "upstream.txt") "pushed to remote"
_ <- runGitIn local ["add", "upstream.txt"]
_ <- runGitIn local ["commit", "-m", "second commit"]
_ <- runGitIn local ["push", "origin", "main"]
-- Rewind local to the initial commit (simulate being behind)
_ <- runGitIn local ["reset", "--hard", "HEAD~1"]
countBefore <- commitCount local
countBefore `shouldBe` 1
let cfg = defaultRepoConfig{rcPath = local}
(code, _, _) <- gitPull logger cfg
code `shouldBe` ExitSuccess
countAfter <- commitCount local
countAfter `shouldBe` 2
it "rebases local commits on top of pulled changes" $ do
logger <- testLogger
withSystemTempDirectory "converge-test-rebase" $ \tmp -> do
let remoteDir = tmp </> "remote.git"
let localDir = tmp </> "local"
-- Create directories before git init
createDirectory remoteDir
createDirectory localDir
-- Create bare remote
_ <- runGitIn remoteDir ["init", "--bare", "-b", "main"]
-- Create local repo, push initial commit
_ <- runGitIn localDir ["init", "-b", "main"]
_ <- runGitIn localDir ["config", "user.name", "test"]
_ <- runGitIn localDir ["config", "user.email", "test@test"]
_ <- runGitIn localDir ["remote", "add", "origin", remoteDir]
writeFile (localDir </> "initial.txt") "initial"
_ <- runGitIn localDir ["add", "initial.txt"]
_ <- runGitIn localDir ["commit", "-m", "initial commit"]
_ <- runGitIn localDir ["push", "-u", "origin", "main"]
-- Make a local-only commit (divergent from remote)
writeFile (localDir </> "local.txt") "local-only change"
_ <- runGitIn localDir ["add", "local.txt"]
_ <- runGitIn localDir ["commit", "-m", "local commit"]
-- Push a *different* commit to remote (from another clone)
let otherDir = tmp </> "other"
_ <- rawGit ["clone", remoteDir, otherDir]
_ <- runGitIn otherDir ["config", "user.name", "test"]
_ <- runGitIn otherDir ["config", "user.email", "test@test"]
writeFile (otherDir </> "remote.txt") "remote-only change"
_ <- runGitIn otherDir ["add", "remote.txt"]
_ <- runGitIn otherDir ["commit", "-m", "remote commit"]
_ <- runGitIn otherDir ["push", "origin", "main"]
-- Pull with --rebase: local commits are rebased on top
-- of the fetched remote commit, yielding a linear history.
let cfg = defaultRepoConfig{rcPath = localDir}
(code, _, _) <- gitPull logger cfg
code `shouldBe` ExitSuccess
-- Rebase produces linear history: 3 commits, no merge commit
c <- commitCount localDir
c `shouldBe` 3
-- Verify no merge commits exist (each commit has exactly 1 parent)
(_, logOut, _) <- runGitIn localDir ["log", "--format=%P", "--all"]
let parentCounts = map (length . words) (lines (T.unpack logOut))
parentCounts `shouldSatisfy` all (<= 1)
+68
View File
@@ -0,0 +1,68 @@
module GitSafetySpec (spec) where
import Converge (checkIndicatorFiles, isInMiddleOfOperation)
import Test.Hspec
import TestHelper
spec :: Spec
spec = do
describe "isInMiddleOfOperation" $ do
it "returns False when no operation is in progress" $ do
withTempGitRepo $ \repo -> do
result <- isInMiddleOfOperation repo
result `shouldBe` False
describe "merge detection" $ do
it "returns True when MERGE_HEAD exists" $ do
withTempGitRepo $ \repo -> do
createMergeHead repo
result <- isInMiddleOfOperation repo
result `shouldBe` True
describe "revert detection" $ do
it "returns True when REVERT_HEAD exists" $ do
withTempGitRepo $ \repo -> do
createRevertHead repo
result <- isInMiddleOfOperation repo
result `shouldBe` True
describe "cherry-pick detection" $ do
it "returns True when CHERRY_PICK_HEAD exists" $ do
withTempGitRepo $ \repo -> do
createCherryPickHead repo
result <- isInMiddleOfOperation repo
result `shouldBe` True
describe "bisect detection" $ do
it "returns True when BISECT_LOG exists" $ do
withTempGitRepo $ \repo -> do
createBisectLog repo
result <- isInMiddleOfOperation repo
result `shouldBe` True
describe "rebase detection" $ do
it "returns True when rebase-apply directory exists" $ do
withTempGitRepo $ \repo -> do
createRebaseApply repo
result <- isInMiddleOfOperation repo
result `shouldBe` True
it "returns True when rebase-merge directory exists" $ do
withTempGitRepo $ \repo -> do
createRebaseMerge repo
result <- isInMiddleOfOperation repo
result `shouldBe` True
describe "sequencer detection" $ do
it "returns True when sequencer directory exists" $ do
withTempGitRepo $ \repo -> do
createSequencer repo
result <- isInMiddleOfOperation repo
result `shouldBe` True
describe "checkIndicatorFiles" $ do
it "returns False for a clean git directory" $ do
withTempGitRepo $ \repo -> do
dir <- gitDir repo
result <- checkIndicatorFiles dir
result `shouldBe` False
+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
}
+8 -3
View File
@@ -1,9 +1,14 @@
module Main (main) where module Main (main) where
import qualified GitCommitSpec
import qualified GitPullSpec
import qualified GitSafetySpec
import qualified RelaySpec
import Test.Hspec import Test.Hspec
main :: IO () main :: IO ()
main = hspec $ do main = hspec $ do
describe "Converge" $ do GitCommitSpec.spec
it "compiles" $ do GitPullSpec.spec
True `shouldBe` True GitSafetySpec.spec
RelaySpec.spec
+138
View File
@@ -0,0 +1,138 @@
module TestHelper (
withTempGitRepo,
withTempGitRepoWithRemote,
rawGit,
runGitIn,
testLogger,
commitCount,
gitDir,
createMergeHead,
createRevertHead,
createCherryPickHead,
createBisectLog,
createRebaseApply,
createRebaseMerge,
createSequencer,
) where
import Control.Concurrent.MVar (newMVar)
import Converge (LogLevel (..), Logger (..))
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (createDirectory)
import System.Exit (ExitCode)
import System.FilePath ((</>))
import System.IO (stderr)
import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode)
-- | Run a git command in the given repository directory.
runGitIn :: FilePath -> [String] -> IO (ExitCode, Text, Text)
runGitIn repoPath args = do
(code, out, err) <- readProcessWithExitCode "git" ("-C" : repoPath : args) ""
pure (code, T.pack out, T.pack err)
-- | Run a git command directly (no -C prefix) -- useful for git clone, etc.
rawGit :: [String] -> IO (ExitCode, Text, Text)
rawGit args = do
(code, out, err) <- readProcessWithExitCode "git" args ""
pure (code, T.pack out, T.pack err)
-- | Create a test logger (serialized writes to stderr, Debug level).
testLogger :: IO Logger
testLogger = Logger <$> newMVar () <*> pure Debug <*> pure stderr
{- | 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.
-}
withTempGitRepo :: (FilePath -> IO a) -> IO a
withTempGitRepo action = withSystemTempDirectory "converge-test" $ \tmp -> do
let repo = tmp </> "repo"
createDirectory repo
_ <- runGitIn repo ["init", "-b", "main"]
_ <- runGitIn repo ["config", "user.name", "converge-test"]
_ <- runGitIn repo ["config", "user.email", "test@converge.local"]
-- Create an initial commit so the repo has a HEAD and git log works.
writeFile (repo </> "initial.txt") "initial"
_ <- runGitIn repo ["add", "initial.txt"]
_ <- runGitIn repo ["commit", "-m", "initial commit"]
action repo
{- | Create a temporary git repo with a bare remote, run the action with the
local repo path, then clean up. The local repo is pushed to the remote
so origin/main is reachable via fetch/pull.
-}
withTempGitRepoWithRemote :: (FilePath -> IO a) -> IO a
withTempGitRepoWithRemote action = withSystemTempDirectory "converge-test-remote" $ \tmp -> do
let remoteDir = tmp </> "remote.git"
let localDir = tmp </> "local"
-- Create bare remote
createDirectory remoteDir
_ <- runGitIn remoteDir ["init", "--bare", "-b", "main"]
-- Create local repo pointing at the remote
createDirectory localDir
_ <- runGitIn localDir ["init", "-b", "main"]
_ <- runGitIn localDir ["config", "user.name", "converge-test"]
_ <- runGitIn localDir ["config", "user.email", "test@converge.local"]
_ <- runGitIn localDir ["remote", "add", "origin", remoteDir]
-- Initial commit + push so origin/main is established
writeFile (localDir </> "initial.txt") "initial"
_ <- runGitIn localDir ["add", "initial.txt"]
_ <- runGitIn localDir ["commit", "-m", "initial commit"]
_ <- runGitIn localDir ["push", "-u", "origin", "main"]
action localDir
-- | Count commits reachable from HEAD.
commitCount :: FilePath -> IO Int
commitCount repo = do
(_, out, _) <- runGitIn repo ["rev-list", "--count", "HEAD"]
let stripped = T.unpack (T.strip out)
case reads stripped of
[(n, "")] -> pure n
_ -> pure 0
-- | Get the .git directory path inside the repository.
gitDir :: FilePath -> IO FilePath
gitDir repo = do
(_, out, _) <- runGitIn repo ["rev-parse", "--git-dir"]
pure (repo </> T.unpack (T.strip out))
----------------------------------------------------------------------
-- Sentinel helpers: create well-known git files/directories inside
-- .git that indicate an in-progress operation.
----------------------------------------------------------------------
createMergeHead :: FilePath -> IO ()
createMergeHead repo = do
dir <- gitDir repo
writeFile (dir </> "MERGE_HEAD") ""
createRevertHead :: FilePath -> IO ()
createRevertHead repo = do
dir <- gitDir repo
writeFile (dir </> "REVERT_HEAD") ""
createCherryPickHead :: FilePath -> IO ()
createCherryPickHead repo = do
dir <- gitDir repo
writeFile (dir </> "CHERRY_PICK_HEAD") ""
createBisectLog :: FilePath -> IO ()
createBisectLog repo = do
dir <- gitDir repo
writeFile (dir </> "BISECT_LOG") ""
createRebaseApply :: FilePath -> IO ()
createRebaseApply repo = do
dir <- gitDir repo
createDirectory (dir </> "rebase-apply")
createRebaseMerge :: FilePath -> IO ()
createRebaseMerge repo = do
dir <- gitDir repo
createDirectory (dir </> "rebase-merge")
createSequencer :: FilePath -> IO ()
createSequencer repo = do
dir <- gitDir repo
createDirectory (dir </> "sequencer")