For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Turn wiki-browser from a local-only server into a participant on a shared master — pulling commits to serve them, pushing incorporations back — deployable on a Raspberry Pi.
Architecture: A new in-binary internal/gitsync package owns one mutex that serializes every git mutation (webhook-triggered fetch, incorporation commit + push, startup catch-up, background retry). A new internal/alert package posts Slack notifications on diverged state, sustained sync/push failure, and sustained Agent-job failure. The webhook is a public HMAC-verified route; incorporation is wrapped so a fetch happens before its stale-check and a push after its commit. All git sync is optional — absent a git: config block the server behaves exactly as today (local dev, dev mode).
Tech Stack: Go 1.x, os/exec git, SQLite (existing collab DB), gopkg.in/yaml.v3, standard net/http, the existing internal/walker / internal/index / internal/realtime packages.
Spec: docs/superpowers/specs/2026-05-21-deployment-git-sync-design.html
New files:
internal/alert/alert.go — Notifier interface, Slack notifier, Nop notifier.internal/alert/alert_test.go — notifier tests.internal/gitsync/git.go — low-level git exec helper.internal/gitsync/gitsync.go — Repo, Config, New, State, Status, SyncResult, DivergedError, reconcile, Sync, Push, Incorporate, status tracking + alert edges.internal/gitsync/run.go — background maintenance loop (Run, retry push, poll, failure-threshold alert).internal/gitsync/helpers_test.go — newTestRepo and git test helpers.internal/gitsync/gitsync_test.go — engine tests.internal/gitsync/run_test.go — background-loop tests.internal/server/handler_webhook.go — handleGitHubWebhook.internal/server/handler_webhook_test.go — webhook tests.internal/server/handler_sync_status.go — handleSyncStatus.internal/server/handler_sync_status_test.go — sync-status tests.Modified files:
internal/config/config.go — add Git/Alert blocks, defaults, validation.internal/walker/walker.go — add Rescan(); refactor scan() into scanInto().internal/server/server.go — Deps.GitSync, Deps.WebhookSecret; register webhook + sync-status routes.internal/server/handler_proposals.go — wrap collab.Incorporate in gitSync.Incorporate.internal/agent/service.go — ServiceConfig.Alerter; consecutive-failure alert.cmd/wiki-browser/main.go — construct alert notifier + gitsync.Repo; startup catch-up; wire Deps; start Run.internal/server/templates/shell.html — diverged banner element.internal/server/static/chrome.js — poll /api/sync-status.internal/server/static/chrome.css — banner styling.deploy/wiki-browser.service — User=karn, env, network ordering.Makefile — deploy target.wiki-browser.example.yaml — git: / alert: blocks.Decomposition note: gitsync is split across git.go (exec), gitsync.go (engine + state), and run.go (background loop) so each file holds one responsibility and stays readable. The engine is one package because the mutex, the state fields, and the operations are a single cohesive unit.
Spec deviation (carry into review): The spec's Operations section says sync-state transitions are broadcast over the realtime hub. The hub (internal/realtime/hub.go) fans out per source_path and has no global channel; a global broadcast would be a hub redesign. This plan instead drives the diverged banner by polling /api/sync-status (Task 14). The Slack alert (Task 8/12) remains the primary, push-based discoverability mechanism, so this is a minor UI-freshness trade-off, not a functional gap.
git: and alert: blocksFiles:
Modify: internal/config/config.go
Test: internal/config/config_test.go
Step 1: Write the failing tests
Add to internal/config/config_test.go. These assume the existing test helpers in that file (writeConfig/temp-file pattern) — if the file builds configs inline, mirror whatever the existing tests do; the assertions below are what matters.
func TestGitBlockDefaultsAndValidation(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(dir, "wh-secret")
if err := os.WriteFile(secret, []byte("s3cr3t"), 0o600); err != nil {
t.Fatal(err)
}
base := minimalValidConfigYAML(t, dir) // existing helper or inline string
yaml := base + "\ngit:\n webhook_secret_file: " + secret + "\n"
c := mustLoadConfigFromString(t, yaml)
if c.Git == nil {
t.Fatal("Git block should be non-nil when present")
}
if c.Git.Remote != "origin" {
t.Errorf("Remote default = %q, want origin", c.Git.Remote)
}
if c.Git.Branch != "master" {
t.Errorf("Branch default = %q, want master", c.Git.Branch)
}
if c.Git.PollInterval != 0 {
t.Errorf("PollInterval default = %v, want 0", c.Git.PollInterval)
}
}
func TestGitBlockRequiresWebhookSecretFile(t *testing.T) {
dir := t.TempDir()
yaml := minimalValidConfigYAML(t, dir) + "\ngit:\n remote: origin\n"
if _, err := loadConfigFromString(t, yaml); err == nil {
t.Fatal("expected error: git.webhook_secret_file required")
}
}
func TestGitBlockRejectsMissingSecretFile(t *testing.T) {
dir := t.TempDir()
yaml := minimalValidConfigYAML(t, dir) +
"\ngit:\n webhook_secret_file: " + filepath.Join(dir, "nope") + "\n"
if _, err := loadConfigFromString(t, yaml); err == nil {
t.Fatal("expected error: webhook_secret_file does not exist")
}
}
func TestAlertBlockValidation(t *testing.T) {
dir := t.TempDir()
url := filepath.Join(dir, "slack-url")
if err := os.WriteFile(url, []byte("https://hooks.slack.com/x"), 0o600); err != nil {
t.Fatal(err)
}
yaml := minimalValidConfigYAML(t, dir) +
"\nalert:\n slack_webhook_url_file: " + url + "\n"
c := mustLoadConfigFromString(t, yaml)
if c.Alert == nil || c.Alert.SlackWebhookURLFile != url {
t.Fatalf("Alert block not parsed: %+v", c.Alert)
}
if c.Alert.FailThreshold != 15*time.Minute {
t.Errorf("FailThreshold default = %v, want 15m", c.Alert.FailThreshold)
}
}
func TestNoGitBlockMeansNilGit(t *testing.T) {
dir := t.TempDir()
c := mustLoadConfigFromString(t, minimalValidConfigYAML(t, dir))
if c.Git != nil {
t.Errorf("Git should be nil when no git: block present")
}
if c.Alert != nil {
t.Errorf("Alert should be nil when no alert: block present")
}
}
If minimalValidConfigYAML / mustLoadConfigFromString / loadConfigFromString do not already exist in config_test.go, add them as thin wrappers: write the YAML to a temp file and call config.Load. minimalValidConfigYAML must produce a config that already passes validate() (valid root, auth, agent — copy the shape from an existing passing test in that file).
Run: go test ./internal/config/ -run 'TestGitBlock|TestAlertBlock|TestNoGitBlock' -v
Expected: FAIL — c.Git undefined / compilation error.
In internal/config/config.go, add to the Config struct (after Agent):
Git *Git `yaml:"git"`
Alert *Alert `yaml:"alert"`
Add the two new types after the Auth type:
// Git configures the gitsync engine. Presence of a `git:` block (Git != nil)
// enables sync; its absence leaves wiki-browser in local-only mode (dev, tests).
type Git struct {
Remote string `yaml:"remote"`
Branch string `yaml:"branch"`
WebhookSecretFile string `yaml:"webhook_secret_file"`
PollInterval time.Duration `yaml:"poll_interval"`
}
// Alert configures the Slack notifier. Optional and independent of Git: when
// absent, gitsync and the Agent service use a no-op notifier.
type Alert struct {
SlackWebhookURLFile string `yaml:"slack_webhook_url_file"`
FailThreshold time.Duration `yaml:"fail_threshold"`
}
In applyDefaults(), append:
if c.Git != nil {
if c.Git.Remote == "" {
c.Git.Remote = "origin"
}
if c.Git.Branch == "" {
c.Git.Branch = "master"
}
}
if c.Alert != nil && c.Alert.FailThreshold == 0 {
c.Alert.FailThreshold = 15 * time.Minute
}
In validate(), before the final return nil, append:
if c.Git != nil {
if c.Git.WebhookSecretFile == "" {
return fmt.Errorf("git.webhook_secret_file is required when a git: block is present")
}
if _, err := os.Stat(c.Git.WebhookSecretFile); err != nil {
return fmt.Errorf("git.webhook_secret_file %s: %w", c.Git.WebhookSecretFile, err)
}
if c.Git.PollInterval < 0 {
return fmt.Errorf("git.poll_interval must not be negative")
}
}
if c.Alert != nil {
if c.Alert.SlackWebhookURLFile == "" {
return fmt.Errorf("alert.slack_webhook_url_file is required when an alert: block is present")
}
if _, err := os.Stat(c.Alert.SlackWebhookURLFile); err != nil {
return fmt.Errorf("alert.slack_webhook_url_file %s: %w", c.Alert.SlackWebhookURLFile, err)
}
if c.Alert.FailThreshold < 0 {
return fmt.Errorf("alert.fail_threshold must not be negative")
}
}
Run: go test ./internal/config/ -v
Expected: PASS (all existing config tests plus the five new ones).
git add internal/config/config.go internal/config/config_test.go
git commit -m "config: add optional git: and alert: blocks"
internal/alert — Slack notifierFiles:
Create: internal/alert/alert.go
Test: internal/alert/alert_test.go
Step 1: Write the failing tests
package alert
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestSlackSendPostsTextPayload(t *testing.T) {
var (
mu sync.Mutex
body []byte
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
mu.Lock()
body = b
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
NewSlack(srv.URL).Send("hello world")
deadline := time.After(2 * time.Second)
for {
mu.Lock()
got := body
mu.Unlock()
if got != nil {
var payload map[string]string
if err := json.Unmarshal(got, &payload); err != nil {
t.Fatalf("payload not JSON: %v", err)
}
if payload["text"] != "hello world" {
t.Fatalf("text = %q, want %q", payload["text"], "hello world")
}
return
}
select {
case <-deadline:
t.Fatal("Slack notifier never POSTed")
case <-time.After(10 * time.Millisecond):
}
}
}
func TestSlackSendDoesNotBlockOnBadURL(t *testing.T) {
done := make(chan struct{})
go func() {
NewSlack("http://127.0.0.1:0/definitely-not-listening").Send("x")
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Send blocked the caller")
}
}
func TestNopSendIsSafe(t *testing.T) {
Nop{}.Send("anything") // must not panic
}
Run: go test ./internal/alert/ -v
Expected: FAIL — package does not exist.
Create internal/alert/alert.go:
// Package alert delivers operator notifications to a Slack incoming webhook.
// Sends are best-effort and asynchronous — a notification must never block or
// fail the operation that triggered it.
package alert
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"time"
)
// Notifier is the narrow surface gitsync and the Agent service depend on.
type Notifier interface {
// Send delivers msg. It must return promptly and never panic.
Send(msg string)
}
// Nop discards every message. Used when no alert webhook is configured.
type Nop struct{}
func (Nop) Send(string) {}
// Slack posts {"text": msg} to a Slack incoming webhook URL.
type Slack struct {
url string
client *http.Client
}
// NewSlack returns a Slack notifier for the given incoming-webhook URL.
func NewSlack(webhookURL string) *Slack {
return &Slack{
url: webhookURL,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// Send POSTs msg asynchronously. A failed delivery is logged and dropped.
func (s *Slack) Send(msg string) {
go func() {
body, err := json.Marshal(map[string]string{"text": msg})
if err != nil {
slog.Warn("alert: marshal failed", "err", err)
return
}
resp, err := s.client.Post(s.url, "application/json", bytes.NewReader(body))
if err != nil {
slog.Warn("alert: slack POST failed", "err", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
slog.Warn("alert: slack POST non-2xx", "status", resp.StatusCode)
}
}()
}
Run: go test ./internal/alert/ -v
Expected: PASS.
git add internal/alert/
git commit -m "alert: add Slack and no-op notifiers"
walker.Rescan()Files:
Modify: internal/walker/walker.go
Test: internal/walker/walker_test.go
Step 1: Write the failing test
Add to internal/walker/walker_test.go:
func TestRescanPicksUpAddedAndRemovedFiles(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.md"), []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
w, err := New(Options{Root: root, Extensions: []string{".md"}})
if err != nil {
t.Fatal(err)
}
if len(w.Files()) != 1 {
t.Fatalf("initial Files() = %v, want [a.md]", w.Files())
}
// Mutate the tree the way a git fast-forward would: add one, remove one.
if err := os.WriteFile(filepath.Join(root, "b.md"), []byte("b"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(root, "a.md")); err != nil {
t.Fatal(err)
}
if err := w.Rescan(); err != nil {
t.Fatalf("Rescan: %v", err)
}
got := w.Files()
if len(got) != 1 || got[0] != "b.md" {
t.Fatalf("after Rescan Files() = %v, want [b.md]", got)
}
if w.Has("a.md") {
t.Error("Has(a.md) should be false after Rescan")
}
}
Run: go test ./internal/walker/ -run TestRescan -v
Expected: FAIL — w.Rescan undefined.
scan and add RescanIn internal/walker/walker.go, replace the scan method so the walk logic targets a caller-supplied map:
func (w *Walker) scan() error {
files := make(map[string]struct{})
if err := w.scanInto(files); err != nil {
return err
}
w.files = files
return nil
}
// scanInto walks the tree and records every matching repo-relative path into m.
func (w *Walker) scanInto(m map[string]struct{}) error {
return filepath.WalkDir(w.opts.Root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if d != nil && d.IsDir() && (errors.Is(err, fs.ErrPermission) || errors.Is(err, fs.ErrNotExist)) {
slog.Warn("walker: skipping unreadable dir", "path", p, "err", err)
return fs.SkipDir
}
return err
}
rel, rerr := filepath.Rel(w.opts.Root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
if rel == "." {
return nil
}
if w.exclude.Match(rel) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
if !w.matchesExt(rel) {
return nil
}
m[rel] = struct{}{}
return nil
})
}
// Rescan re-walks the tree and atomically replaces the file set. Used after a
// git operation changes many files at once, so the served view is correct
// without depending on fsnotify delivery timing.
func (w *Walker) Rescan() error {
fresh := make(map[string]struct{})
if err := w.scanInto(fresh); err != nil {
return err
}
w.mu.Lock()
w.files = fresh
w.mu.Unlock()
return nil
}
Note: New already calls w.scan(); that path is preserved. scan() keeps writing w.files directly (it runs before any concurrency, inside New). Rescan swaps under the lock.
Run: go test ./internal/walker/ -v
Expected: PASS (all existing walker tests plus TestRescan...).
git add internal/walker/walker.go internal/walker/walker_test.go
git commit -m "walker: add Rescan for deterministic post-git-op refresh"
gitsync — git exec helper + test scaffolding + NewFiles:
Create: internal/gitsync/git.go
Create: internal/gitsync/gitsync.go (partial — Config, Repo, New only)
Create: internal/gitsync/helpers_test.go
Test: internal/gitsync/gitsync_test.go
Step 1: Write the test helpers
Create internal/gitsync/helpers_test.go:
package gitsync
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// mustGit runs git in dir and fails the test on error.
func mustGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
// newTestRepo builds a bare origin plus a working clone checked out on master,
// with one initial commit pushed. Returns the working-tree path and the origin
// path.
func newTestRepo(t *testing.T) (root, origin string) {
t.Helper()
dir := t.TempDir()
origin = filepath.Join(dir, "origin.git")
root = filepath.Join(dir, "work")
mustGit(t, dir, "init", "--bare", "-b", "master", origin)
mustGit(t, dir, "clone", origin, root)
mustGit(t, root, "config", "user.name", "test")
mustGit(t, root, "config", "user.email", "test@test")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, root, "add", "README.md")
mustGit(t, root, "commit", "-m", "init")
mustGit(t, root, "push", "-u", "origin", "master")
return root, origin
}
// commitInOrigin makes a commit directly on origin (simulating a teammate's
// push) by cloning origin to a scratch dir, committing, and pushing.
func commitInOrigin(t *testing.T, origin, relPath, content string) {
t.Helper()
scratch := t.TempDir()
mustGit(t, scratch, "clone", origin, "clone")
clone := filepath.Join(scratch, "clone")
mustGit(t, clone, "config", "user.name", "test")
mustGit(t, clone, "config", "user.email", "test@test")
full := filepath.Join(clone, relPath)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, clone, "add", relPath)
mustGit(t, clone, "commit", "-m", "origin: "+relPath)
mustGit(t, clone, "push", "origin", "master")
}
func defaultConfig(root string) Config {
return Config{
Root: root, Remote: "origin", Branch: "master",
FailThreshold: time.Minute,
}
}
(The time import in defaultConfig requires "time" in the helper file's import block — add it.)
New testsCreate internal/gitsync/gitsync_test.go:
package gitsync
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/getorcha/wiki-browser/internal/alert"
)
func TestNewValidatesRepo(t *testing.T) {
root, _ := newTestRepo(t)
r, err := New(defaultConfig(root), alert.Nop{}, "https://wiki.example.com")
if err != nil {
t.Fatalf("New: %v", err)
}
if got := r.Status().State; got != StateSynced {
t.Errorf("initial state = %q, want synced", got)
}
if r.Status().Head == "" {
t.Error("initial Head is empty")
}
}
func TestNewRejectsNonRepo(t *testing.T) {
if _, err := New(defaultConfig(t.TempDir()), alert.Nop{}, ""); err == nil {
t.Fatal("expected error for non-git directory")
}
}
func TestNewRejectsWrongBranch(t *testing.T) {
root, _ := newTestRepo(t)
mustGit(t, root, "checkout", "-b", "feature")
if _, err := New(defaultConfig(root), alert.Nop{}, ""); err == nil {
t.Fatal("expected error: HEAD not on configured branch")
}
}
Run: go test ./internal/gitsync/ -run TestNew -v
Expected: FAIL — package incomplete.
git.gopackage gitsync
import (
"fmt"
"os/exec"
"strings"
)
// git runs a git subcommand in repoRoot and returns its trimmed combined
// output. On failure the error includes the command and output for diagnosis.
func git(repoRoot string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", repoRoot}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, string(out))
}
return strings.TrimSpace(string(out)), nil
}
gitsync.go (Config, Repo, New, Status)// Package gitsync keeps a working clone in step with a shared remote branch:
// it fast-forwards in commits that land upstream and pushes the commits the
// wiki-browser process makes locally. One mutex serializes every git mutation.
package gitsync
import (
"fmt"
"strings"
"sync"
"time"
"github.com/getorcha/wiki-browser/internal/alert"
)
// Config is the fully-specified input to New.
type Config struct {
Root string // working-tree path (cfg.Root)
Remote string // e.g. "origin"
Branch string // e.g. "master"
PollInterval time.Duration // 0 disables the safety-net poll
FailThreshold time.Duration // sustained-failure alert threshold
Extensions []string // file extensions worth reindexing (e.g. .md .html)
}
// State is the coarse sync status surfaced to operators.
type State string
const (
StateSynced State = "synced"
StateSyncing State = "syncing"
StatePushPending State = "push-pending"
StateDiverged State = "diverged"
)
// Status is the JSON-serializable snapshot returned by /api/sync-status.
type Status struct {
State State `json:"state"`
Head string `json:"head"`
Ahead int `json:"ahead"`
LastSyncAt time.Time `json:"last_sync_at"`
LastError string `json:"last_error,omitempty"`
}
// SyncResult describes what a single reconcile changed.
type SyncResult struct {
OldHead string
NewHead string
ChangedPaths []string // repo-relative, filtered to Config.Extensions
Rebased bool
}
// Repo is the git-sync engine for one clone.
type Repo struct {
cfg Config
notifier alert.Notifier
publicURL string // public_base_url, for diverged-alert doc links
gitMu sync.Mutex // serializes ALL git mutations
onSync func(SyncResult) // runtime reindex hook; set via SetOnSync
syncReq chan struct{} // coalesced sync requests from the webhook
stMu sync.Mutex // guards every field below
state State
head string
lastSyncAt time.Time
lastErr string
ahead int
failingSince time.Time // zero ⇒ not currently failing
failAlerted bool // threshold alert already sent for this failure run
}
// New validates the clone and returns an engine in the synced state.
func New(cfg Config, notifier alert.Notifier, publicBaseURL string) (*Repo, error) {
if cfg.Root == "" || cfg.Remote == "" || cfg.Branch == "" {
return nil, fmt.Errorf("gitsync: Root, Remote and Branch are required")
}
if notifier == nil {
notifier = alert.Nop{}
}
if out, err := git(cfg.Root, "rev-parse", "--is-inside-work-tree"); err != nil || out != "true" {
return nil, fmt.Errorf("gitsync: %s is not a git work tree", cfg.Root)
}
branch, err := git(cfg.Root, "symbolic-ref", "--short", "HEAD")
if err != nil {
return nil, fmt.Errorf("gitsync: read current branch: %w", err)
}
if branch != cfg.Branch {
return nil, fmt.Errorf("gitsync: HEAD is on %q, want %q", branch, cfg.Branch)
}
if _, err := git(cfg.Root, "remote", "get-url", cfg.Remote); err != nil {
return nil, fmt.Errorf("gitsync: remote %q: %w", cfg.Remote, err)
}
head, err := git(cfg.Root, "rev-parse", "HEAD")
if err != nil {
return nil, fmt.Errorf("gitsync: rev-parse HEAD: %w", err)
}
return &Repo{
cfg: cfg,
notifier: notifier,
publicURL: strings.TrimRight(publicBaseURL, "/"),
syncReq: make(chan struct{}, 1),
state: StateSynced,
head: head,
}, nil
}
// Branch returns the configured branch name.
func (r *Repo) Branch() string { return r.cfg.Branch }
// SetOnSync registers the callback invoked after a reconcile changes files.
// Set once, before Run starts.
func (r *Repo) SetOnSync(fn func(SyncResult)) { r.onSync = fn }
// Status returns a snapshot of the current sync status.
func (r *Repo) Status() Status {
r.stMu.Lock()
defer r.stMu.Unlock()
return Status{
State: r.state,
Head: r.head,
Ahead: r.ahead,
LastSyncAt: r.lastSyncAt,
LastError: r.lastErr,
}
}
Run: go test ./internal/gitsync/ -run TestNew -v
Expected: PASS.
git add internal/gitsync/
git commit -m "gitsync: repo validation, config, status types"
gitsync — reconcile and DivergedErrorFiles:
Modify: internal/gitsync/gitsync.go
Test: internal/gitsync/gitsync_test.go
Step 1: Write the failing tests
Add to internal/gitsync/gitsync_test.go:
func TestReconcileFastForwards(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(Config{Root: root, Remote: "origin", Branch: "master",
FailThreshold: time.Minute, Extensions: []string{".md"}}, alert.Nop{}, "")
commitInOrigin(t, origin, "docs/new.md", "fresh\n")
res, err := r.reconcileLocked()
if err != nil {
t.Fatalf("reconcileLocked: %v", err)
}
if res.OldHead == res.NewHead {
t.Fatal("HEAD did not advance")
}
if len(res.ChangedPaths) != 1 || res.ChangedPaths[0] != "docs/new.md" {
t.Fatalf("ChangedPaths = %v, want [docs/new.md]", res.ChangedPaths)
}
if _, err := os.Stat(filepath.Join(root, "docs/new.md")); err != nil {
t.Errorf("file not on disk after ff: %v", err)
}
}
func TestReconcileFiltersChangedPathsByExtension(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(Config{Root: root, Remote: "origin", Branch: "master",
FailThreshold: time.Minute, Extensions: []string{".md"}}, alert.Nop{}, "")
commitInOrigin(t, origin, "main.go", "package x\n")
res, err := r.reconcileLocked()
if err != nil {
t.Fatalf("reconcileLocked: %v", err)
}
if len(res.ChangedPaths) != 0 {
t.Fatalf("ChangedPaths = %v, want [] (non-.md filtered out)", res.ChangedPaths)
}
}
func TestReconcileRebasesLocalCommits(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(defaultConfig(root), alert.Nop{}, "")
// Local commit not yet pushed.
if err := os.WriteFile(filepath.Join(root, "local.md"), []byte("local\n"), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, root, "add", "local.md")
mustGit(t, root, "commit", "-m", "local work")
// Divergent commit upstream, on a different file.
commitInOrigin(t, origin, "remote.md", "remote\n")
res, err := r.reconcileLocked()
if err != nil {
t.Fatalf("reconcileLocked: %v", err)
}
if !res.Rebased {
t.Error("expected Rebased=true")
}
// Local commit must survive the rebase.
if _, err := os.Stat(filepath.Join(root, "local.md")); err != nil {
t.Errorf("local commit lost after rebase: %v", err)
}
}
func TestReconcileReportsDivergedOnConflict(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(defaultConfig(root), alert.Nop{}, "")
// Both sides edit README.md → guaranteed rebase conflict.
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("local edit\n"), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, root, "add", "README.md")
mustGit(t, root, "commit", "-m", "local readme")
commitInOrigin(t, origin, "README.md", "remote edit\n")
_, err := r.reconcileLocked()
var de *DivergedError
if !errorAs(err, &de) {
t.Fatalf("err = %v, want *DivergedError", err)
}
if len(de.Paths) == 0 {
t.Error("DivergedError.Paths is empty")
}
// Rebase must have been aborted — tree is clean, local commit intact.
if out := mustGit(t, root, "status", "--porcelain"); out != "" {
t.Errorf("tree not clean after abort: %q", out)
}
}
Add a tiny errorAs helper to the test file (or use errors.As directly — import "errors"):
func errorAs(err error, target any) bool { return errors.As(err, target) }
Run: go test ./internal/gitsync/ -run TestReconcile -v
Expected: FAIL — reconcileLocked / DivergedError undefined.
DivergedError and reconcileLockedAdd to internal/gitsync/gitsync.go:
// DivergedError reports that local and remote both changed the same file(s),
// so a rebase could not complete automatically. The local commit is preserved;
// a human must resolve the merge on the host.
type DivergedError struct {
Paths []string
}
func (e *DivergedError) Error() string {
return "gitsync: diverged on " + strings.Join(e.Paths, ", ")
}
// reconcileLocked fetches the remote branch and brings the local branch in
// line: fast-forward when possible, rebase when local commits exist, and a
// *DivergedError when the rebase cannot complete. The caller MUST hold gitMu.
//
// On a successful change it invokes the onSync callback (if set) so the served
// view is refreshed deterministically.
func (r *Repo) reconcileLocked() (SyncResult, error) {
remoteRef := r.cfg.Remote + "/" + r.cfg.Branch
oldHead, err := git(r.cfg.Root, "rev-parse", "HEAD")
if err != nil {
return SyncResult{}, err
}
if _, err := git(r.cfg.Root, "fetch", r.cfg.Remote, r.cfg.Branch); err != nil {
return SyncResult{}, err
}
behind, err := countCommits(r.cfg.Root, "HEAD.."+remoteRef)
if err != nil {
return SyncResult{}, err
}
ahead, err := countCommits(r.cfg.Root, remoteRef+"..HEAD")
if err != nil {
return SyncResult{}, err
}
res := SyncResult{OldHead: oldHead}
if behind > 0 {
if ahead == 0 {
if _, err := git(r.cfg.Root, "merge", "--ff-only", remoteRef); err != nil {
return SyncResult{}, err
}
} else {
if _, err := git(r.cfg.Root, "rebase", remoteRef); err != nil {
conflicted, _ := git(r.cfg.Root, "diff", "--name-only", "--diff-filter=U")
_, _ = git(r.cfg.Root, "rebase", "--abort")
paths := splitLines(conflicted)
if len(paths) == 0 {
paths = []string{"(unknown)"}
}
return SyncResult{}, &DivergedError{Paths: paths}
}
res.Rebased = true
}
}
newHead, err := git(r.cfg.Root, "rev-parse", "HEAD")
if err != nil {
return SyncResult{}, err
}
res.NewHead = newHead
if oldHead != newHead {
raw, err := git(r.cfg.Root, "diff", "--name-only", oldHead, newHead)
if err != nil {
return SyncResult{}, err
}
res.ChangedPaths = r.filterByExtension(splitLines(raw))
if r.onSync != nil {
r.onSync(res)
}
}
return res, nil
}
// countCommits returns the number of commits in the given rev range.
func countCommits(root, revRange string) (int, error) {
out, err := git(root, "rev-list", "--count", revRange)
if err != nil {
return 0, err
}
n := 0
if _, err := fmt.Sscanf(out, "%d", &n); err != nil {
return 0, fmt.Errorf("gitsync: parse commit count %q: %w", out, err)
}
return n, nil
}
// filterByExtension keeps only paths whose extension is in Config.Extensions.
// An empty Extensions list keeps everything.
func (r *Repo) filterByExtension(paths []string) []string {
if len(r.cfg.Extensions) == 0 {
return paths
}
out := make([]string, 0, len(paths))
for _, p := range paths {
for _, ext := range r.cfg.Extensions {
if strings.HasSuffix(strings.ToLower(p), strings.ToLower(ext)) {
out = append(out, p)
break
}
}
}
return out
}
// splitLines splits git output into non-empty trimmed lines.
func splitLines(s string) []string {
var out []string
for _, ln := range strings.Split(s, "\n") {
if ln = strings.TrimSpace(ln); ln != "" {
out = append(out, ln)
}
}
return out
}
Run: go test ./internal/gitsync/ -run TestReconcile -v
Expected: PASS.
git add internal/gitsync/gitsync.go internal/gitsync/gitsync_test.go
git commit -m "gitsync: reconcile (fetch, ff/rebase, diverged detection)"
gitsync — state transitions, alerts, and SyncFiles:
Modify: internal/gitsync/gitsync.go
Test: internal/gitsync/gitsync_test.go
Step 1: Write the failing tests
Add to internal/gitsync/gitsync_test.go. Use a recording notifier:
type recordingNotifier struct {
mu sync.Mutex
msgs []string
}
func (n *recordingNotifier) Send(msg string) {
n.mu.Lock()
defer n.mu.Unlock()
n.msgs = append(n.msgs, msg)
}
func (n *recordingNotifier) all() []string {
n.mu.Lock()
defer n.mu.Unlock()
return append([]string(nil), n.msgs...)
}
func TestSyncFastForwardUpdatesStatus(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(Config{Root: root, Remote: "origin", Branch: "master",
FailThreshold: time.Minute, Extensions: []string{".md"}}, alert.Nop{}, "")
commitInOrigin(t, origin, "docs/x.md", "x\n")
res, err := r.Sync(context.Background())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if len(res.ChangedPaths) != 1 {
t.Errorf("ChangedPaths = %v", res.ChangedPaths)
}
st := r.Status()
if st.State != StateSynced {
t.Errorf("state = %q, want synced", st.State)
}
if st.LastSyncAt.IsZero() {
t.Error("LastSyncAt not set")
}
}
func TestSyncInvokesOnSyncCallback(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(Config{Root: root, Remote: "origin", Branch: "master",
FailThreshold: time.Minute, Extensions: []string{".md"}}, alert.Nop{}, "")
var got []string
r.SetOnSync(func(res SyncResult) { got = res.ChangedPaths })
commitInOrigin(t, origin, "docs/cb.md", "cb\n")
if _, err := r.Sync(context.Background()); err != nil {
t.Fatalf("Sync: %v", err)
}
if len(got) != 1 || got[0] != "docs/cb.md" {
t.Fatalf("onSync got %v, want [docs/cb.md]", got)
}
}
func TestSyncDivergedSetsStateAndAlertsWithDocLink(t *testing.T) {
root, origin := newTestRepo(t)
n := &recordingNotifier{}
r, _ := New(defaultConfig(root), n, "https://wiki.example.com")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("local\n"), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, root, "add", "README.md")
mustGit(t, root, "commit", "-m", "local")
commitInOrigin(t, origin, "README.md", "remote\n")
_, err := r.Sync(context.Background())
if err == nil {
t.Fatal("expected diverged error")
}
if r.Status().State != StateDiverged {
t.Errorf("state = %q, want diverged", r.Status().State)
}
msgs := n.all()
if len(msgs) != 1 {
t.Fatalf("alerts = %v, want exactly one", msgs)
}
if !strings.Contains(msgs[0], "https://wiki.example.com/doc/README.md") {
t.Errorf("alert missing doc link: %q", msgs[0])
}
}
(Imports needed in the test file: context, strings, sync, time, errors, os, path/filepath, testing, and internal/alert.)
Run: go test ./internal/gitsync/ -run TestSync -v
Expected: FAIL — Sync undefined.
SyncAdd to internal/gitsync/gitsync.go:
import "context" // add to the existing import block
// setState records a new coarse state and fires the edge-triggered alerts:
// entering diverged alerts immediately; returning to synced from a bad state
// fires a single recovery alert. Caller must NOT hold stMu.
func (r *Repo) setState(s State, divergedPaths []string) {
r.stMu.Lock()
old := r.state
r.state = s
wasAlerted := r.failAlerted
r.stMu.Unlock()
switch {
case s == StateDiverged && old != StateDiverged:
r.notifier.Send("wiki-browser: git sync DIVERGED — manual resolution needed on the host.\n" +
r.alertContext("diverged") +
r.docLinks(divergedPaths))
case s == StateSynced && (old == StateDiverged || wasAlerted):
r.notifier.Send("wiki-browser: git sync RECOVERED — back in sync.\n" +
r.alertContext("recovered"))
r.stMu.Lock()
r.failAlerted = false
r.stMu.Unlock()
}
}
// docLinks renders one public-base-URL deep link per path.
func (r *Repo) docLinks(paths []string) string {
var b strings.Builder
for _, p := range paths {
if r.publicURL != "" {
fmt.Fprintf(&b, "%s/doc/%s\n", r.publicURL, p)
} else {
fmt.Fprintf(&b, "%s\n", p)
}
}
return b.String()
}
// alertContext returns the operator context every alert must carry.
func (r *Repo) alertContext(condition string) string {
st := r.Status()
lastErr := st.LastError
if lastErr == "" {
lastErr = "(none)"
}
head := st.Head
if head == "" {
head = "(unknown)"
}
return fmt.Sprintf("condition: %s\nstate: %s\nhead: %s\nlast_error: %s\n",
condition, st.State, head, lastErr)
}
// recordOK clears failure tracking and refreshes head/ahead/lastSyncAt.
func (r *Repo) recordOK() {
head, _ := git(r.cfg.Root, "rev-parse", "HEAD")
ahead, _ := countCommits(r.cfg.Root, r.cfg.Remote+"/"+r.cfg.Branch+"..HEAD")
r.stMu.Lock()
r.head = head
r.ahead = ahead
r.lastSyncAt = time.Now()
r.lastErr = ""
r.failingSince = time.Time{}
r.stMu.Unlock()
}
// recordFailure stamps failingSince (once per failure run) and the last error.
func (r *Repo) recordFailure(err error) {
head, _ := git(r.cfg.Root, "rev-parse", "HEAD")
r.stMu.Lock()
if r.failingSince.IsZero() {
r.failingSince = time.Now()
}
if head != "" {
r.head = head
}
r.lastErr = err.Error()
r.stMu.Unlock()
}
// Sync fetches and reconciles under the git lock. Webhook- and startup-driven.
func (r *Repo) Sync(ctx context.Context) (SyncResult, error) {
_ = ctx // reserved for future cancellation; git calls are short
r.gitMu.Lock()
defer r.gitMu.Unlock()
r.setState(StateSyncing, nil)
res, err := r.reconcileLocked()
if err != nil {
var de *DivergedError
if errors.As(err, &de) {
r.recordFailure(err)
r.setState(StateDiverged, de.Paths)
return SyncResult{}, err
}
r.recordFailure(err)
r.setState(StatePushPending, nil) // unknown push state; retried by Run
return SyncResult{}, err
}
r.recordOK()
if r.statusAhead() > 0 {
r.setState(StatePushPending, nil)
} else {
r.setState(StateSynced, nil)
}
return res, nil
}
// statusAhead returns the cached ahead count.
func (r *Repo) statusAhead() int {
r.stMu.Lock()
defer r.stMu.Unlock()
return r.ahead
}
Add "errors" to the import block if not present.
Run: go test ./internal/gitsync/ -run TestSync -v
Expected: PASS.
git add internal/gitsync/gitsync.go internal/gitsync/gitsync_test.go
git commit -m "gitsync: Sync, state transitions, diverged alerting"
gitsync — Push and IncorporateFiles:
Modify: internal/gitsync/gitsync.go
Test: internal/gitsync/gitsync_test.go
Step 1: Write the failing tests
func TestPushSendsLocalCommitToOrigin(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(defaultConfig(root), alert.Nop{}, "")
if err := os.WriteFile(filepath.Join(root, "p.md"), []byte("p\n"), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, root, "add", "p.md")
mustGit(t, root, "commit", "-m", "local")
if err := r.Push(context.Background()); err != nil {
t.Fatalf("Push: %v", err)
}
if r.Status().State != StateSynced {
t.Errorf("state = %q, want synced", r.Status().State)
}
// origin now has the commit.
scratch := t.TempDir()
mustGit(t, scratch, "clone", origin, "verify")
if _, err := os.Stat(filepath.Join(scratch, "verify", "p.md")); err != nil {
t.Errorf("commit not on origin: %v", err)
}
}
func TestPushRebasesWhenOriginMoved(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(defaultConfig(root), alert.Nop{}, "")
if err := os.WriteFile(filepath.Join(root, "mine.md"), []byte("mine\n"), 0o644); err != nil {
t.Fatal(err)
}
mustGit(t, root, "add", "mine.md")
mustGit(t, root, "commit", "-m", "mine")
commitInOrigin(t, origin, "theirs.md", "theirs\n")
if err := r.Push(context.Background()); err != nil {
t.Fatalf("Push: %v", err)
}
if r.Status().State != StateSynced {
t.Errorf("state = %q, want synced", r.Status().State)
}
}
func TestIncorporateReconcilesThenRunsFnThenPushes(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(defaultConfig(root), alert.Nop{}, "")
commitInOrigin(t, origin, "upstream.md", "up\n")
sawUpstream := false
sha, err := r.Incorporate(context.Background(), func() (string, error) {
// fn runs AFTER reconcile — the upstream file must already be present.
_, statErr := os.Stat(filepath.Join(root, "upstream.md"))
sawUpstream = statErr == nil
if err := os.WriteFile(filepath.Join(root, "inc.md"), []byte("inc\n"), 0o644); err != nil {
return "", err
}
mustGit(t, root, "add", "inc.md")
mustGit(t, root, "commit", "-m", "incorporated")
out := mustGit(t, root, "rev-parse", "HEAD")
return out, nil
})
if err != nil {
t.Fatalf("Incorporate: %v", err)
}
if !sawUpstream {
t.Error("fn ran before reconcile — upstream file was not present")
}
if sha == "" {
t.Error("Incorporate returned empty sha")
}
scratch := t.TempDir()
mustGit(t, scratch, "clone", origin, "v")
if _, err := os.Stat(filepath.Join(scratch, "v", "inc.md")); err != nil {
t.Errorf("incorporation not pushed to origin: %v", err)
}
}
func TestIncorporateReturnsFnError(t *testing.T) {
root, _ := newTestRepo(t)
r, _ := New(defaultConfig(root), alert.Nop{}, "")
want := errors.New("stale proposal")
_, err := r.Incorporate(context.Background(), func() (string, error) {
return "", want
})
if !errors.Is(err, want) {
t.Fatalf("err = %v, want %v", err, want)
}
}
func TestIncorporatePreReconcileFailureDoesNotRunFn(t *testing.T) {
root, _ := newTestRepo(t)
// Break fetch before constructing the Repo. remote get-url still succeeds,
// but the mandatory pre-incorporation reconcile will fail.
mustGit(t, root, "remote", "set-url", "origin", "file:///nonexistent/repo.git")
r, _ := New(defaultConfig(root), alert.Nop{}, "")
ran := false
_, err := r.Incorporate(context.Background(), func() (string, error) {
ran = true
return "should-not-happen", nil
})
if err == nil {
t.Fatal("expected pre-reconcile error")
}
if ran {
t.Fatal("Incorporate ran fn after failed pre-reconcile")
}
if got := r.Status().State; got != StatePushPending {
t.Fatalf("state = %q, want push-pending", got)
}
}
Run: go test ./internal/gitsync/ -run 'TestPush|TestIncorporate' -v
Expected: FAIL — Push / Incorporate undefined.
pushLocked, Push, IncorporateAdd to internal/gitsync/gitsync.go:
// maxPushAttempts bounds the reconcile+push retry loop.
const maxPushAttempts = 3
// pushLocked pushes local commits to the remote branch, reconciling and
// retrying on a non-fast-forward rejection. Caller MUST hold gitMu.
func (r *Repo) pushLocked() error {
remoteRef := r.cfg.Remote + "/" + r.cfg.Branch
var lastErr error
for attempt := 0; attempt < maxPushAttempts; attempt++ {
ahead, err := countCommits(r.cfg.Root, remoteRef+"..HEAD")
if err != nil {
return err
}
if ahead == 0 {
return nil
}
if _, err := git(r.cfg.Root, "push", r.cfg.Remote, r.cfg.Branch); err == nil {
return nil
} else {
lastErr = err
}
// Rejected — origin moved. Reconcile and retry.
if _, err := r.reconcileLocked(); err != nil {
return err // includes *DivergedError
}
}
return lastErr
}
// Push reconciles, then pushes any local commits. Best-effort: a failure leaves
// the engine in push-pending for the background loop to retry.
func (r *Repo) Push(ctx context.Context) error {
_ = ctx
r.gitMu.Lock()
defer r.gitMu.Unlock()
return r.pushAndSetState()
}
// pushAndSetState runs reconcile+push and records the resulting state.
// Caller MUST hold gitMu.
func (r *Repo) pushAndSetState() error {
if _, err := r.reconcileLocked(); err != nil {
var de *DivergedError
if errors.As(err, &de) {
r.recordFailure(err)
r.setState(StateDiverged, de.Paths)
return err
}
r.recordFailure(err)
r.setState(StatePushPending, nil)
return err
}
if err := r.pushLocked(); err != nil {
var de *DivergedError
if errors.As(err, &de) {
r.recordFailure(err)
r.setState(StateDiverged, de.Paths)
return err
}
r.recordFailure(err)
r.setState(StatePushPending, nil)
return err
}
r.recordOK()
r.setState(StateSynced, nil)
return nil
}
// Incorporate reconciles, runs fn (the collab.Incorporate call) under the git
// lock, then pushes. fn's error is returned verbatim and suppresses the push.
// A *DivergedError from the pre-reconcile is returned without running fn. A
// push failure after a successful fn does NOT fail the call — the commit is
// local and authoritative; the background loop retries the push.
func (r *Repo) Incorporate(ctx context.Context, fn func() (string, error)) (string, error) {
_ = ctx
r.gitMu.Lock()
defer r.gitMu.Unlock()
r.setState(StateSyncing, nil)
if _, err := r.reconcileLocked(); err != nil {
var de *DivergedError
if errors.As(err, &de) {
r.recordFailure(err)
r.setState(StateDiverged, de.Paths)
return "", err
}
// The pre-incorporation reconcile is mandatory: collab.Incorporate's
// stale-check must see the freshly-pulled source. A network/fetch
// failure is retryable, but it must not run fn against stale files.
r.recordFailure(err)
r.setState(StatePushPending, nil)
return "", err
}
sha, ferr := fn()
if ferr != nil {
// Incorporation did not happen; restore a coherent state.
if r.statusAhead() > 0 {
r.setState(StatePushPending, nil)
} else {
r.setState(StateSynced, nil)
}
return "", ferr
}
if err := r.pushLocked(); err != nil {
var de *DivergedError
if errors.As(err, &de) {
r.recordFailure(err)
r.setState(StateDiverged, de.Paths)
} else {
r.recordFailure(err)
r.setState(StatePushPending, nil)
}
return sha, nil // incorporation succeeded; push is retried later
}
r.recordOK()
r.setState(StateSynced, nil)
return sha, nil
}
Run: go test ./internal/gitsync/ -v
Expected: PASS (all gitsync tests).
git add internal/gitsync/gitsync.go internal/gitsync/gitsync_test.go
git commit -m "gitsync: Push and Incorporate"
gitsync — background loop, retry, failure-threshold alertFiles:
Create: internal/gitsync/run.go
Test: internal/gitsync/run_test.go
Step 1: Write the failing tests
Create internal/gitsync/run_test.go:
package gitsync
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/getorcha/wiki-browser/internal/alert"
)
func TestRequestSyncIsPickedUpByRun(t *testing.T) {
root, origin := newTestRepo(t)
r, _ := New(Config{Root: root, Remote: "origin", Branch: "master",
FailThreshold: time.Minute, Extensions: []string{".md"}}, alert.Nop{}, "")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go r.Run(ctx)
commitInOrigin(t, origin, "docs/req.md", "req\n")
r.RequestSync()
deadline := time.After(3 * time.Second)
for {
if _, err := os.Stat(filepath.Join(root, "docs/req.md")); err == nil {
return
}
select {
case <-deadline:
t.Fatal("RequestSync did not trigger a sync")
case <-time.After(20 * time.Millisecond):
}
}
}
func TestThresholdAlertFiresAfterSustainedFailure(t *testing.T) {
root, _ := newTestRepo(t)
// Point the remote at a dead URL so every fetch fails.
mustGit(t, root, "remote", "set-url", "origin", "file:///nonexistent/repo.git")
n := &recordingNotifier{}
r, _ := New(Config{Root: root, Remote: "origin", Branch: "master",
FailThreshold: 50 * time.Millisecond, Extensions: []string{".md"}}, n, "")
// First failure stamps failingSince.
_, _ = r.Sync(context.Background())
time.Sleep(80 * time.Millisecond)
r.evaluateFailureThreshold()
msgs := n.all()
if len(msgs) != 1 || !strings.Contains(msgs[0], "FAILING") {
t.Fatalf("alerts = %v, want one FAILING alert", msgs)
}
// Idempotent — a second evaluation must not re-alert.
r.evaluateFailureThreshold()
if got := n.all(); len(got) != 1 {
t.Fatalf("alerts = %v, want still one (edge-triggered)", got)
}
}
Run: go test ./internal/gitsync/ -run 'TestRequestSync|TestThreshold' -v
Expected: FAIL — Run / RequestSync / evaluateFailureThreshold undefined.
run.gopackage gitsync
import (
"context"
"time"
)
// maintenanceInterval is the background loop's tick: retry pending pushes,
// run the safety-net poll, and evaluate the failure threshold.
const maintenanceInterval = time.Minute
// RequestSync asks the background loop to run a Sync. Non-blocking and
// coalesced: bursts of webhook deliveries collapse to at most one pending sync.
func (r *Repo) RequestSync() {
select {
case r.syncReq <- struct{}{}:
default:
}
}
// Run is the background maintenance loop. Blocks until ctx is cancelled; start
// it in a goroutine.
func (r *Repo) Run(ctx context.Context) {
tick := time.NewTicker(maintenanceInterval)
defer tick.Stop()
var lastPoll time.Time
for {
select {
case <-ctx.Done():
return
case <-r.syncReq:
_, _ = r.Sync(ctx)
case <-tick.C:
r.maintenance(ctx, &lastPoll)
}
}
}
// maintenance retries a pending push, runs the safety-net poll when due, and
// evaluates the failure threshold.
func (r *Repo) maintenance(ctx context.Context, lastPoll *time.Time) {
switch r.Status().State {
case StatePushPending:
_ = r.Push(ctx)
case StateDiverged:
// Needs a human — do not thrash retries.
default:
if r.statusAhead() > 0 {
_ = r.Push(ctx)
}
}
if r.cfg.PollInterval > 0 && time.Since(*lastPoll) >= r.cfg.PollInterval {
_, _ = r.Sync(ctx)
*lastPoll = time.Now()
}
r.evaluateFailureThreshold()
}
// evaluateFailureThreshold fires a single FAILING alert once sustained sync/
// push failures exceed Config.FailThreshold. Edge-triggered; recovery is
// handled by setState. Safe to call repeatedly.
//
// A diverged repo is skipped: diverged has its own immediate, dedicated alert
// (see setState). The threshold alert exists for *silent* push-pending
// failures, so emitting a "FAILING" alert for an already-announced diverged
// state would be a misleading duplicate.
func (r *Repo) evaluateFailureThreshold() {
if r.Status().State == StateDiverged {
return
}
r.stMu.Lock()
failing := !r.failingSince.IsZero()
elapsed := time.Since(r.failingSince)
alreadyAlerted := r.failAlerted
r.stMu.Unlock()
if !failing || alreadyAlerted || elapsed < r.cfg.FailThreshold {
return
}
r.stMu.Lock()
r.failAlerted = true
r.stMu.Unlock()
r.notifier.Send("wiki-browser: git sync FAILING for " +
elapsed.Round(time.Second).String() + ".\n" +
r.alertContext("sustained sync/push failure"))
}
Run: go test ./internal/gitsync/ -v
Expected: PASS.
git add internal/gitsync/run.go internal/gitsync/run_test.go
git commit -m "gitsync: background loop, push retry, failure-threshold alert"
Files:
Create: internal/server/handler_webhook.go
Modify: internal/server/server.go
Test: internal/server/handler_webhook_test.go
Step 1: Write the failing tests
Create internal/server/handler_webhook_test.go:
package server
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func sign(secret, body []byte) string {
m := hmac.New(sha256.New, secret)
m.Write(body)
return "sha256=" + hex.EncodeToString(m.Sum(nil))
}
func TestWebhookRejectsMissingSignature(t *testing.T) {
d := Deps{WebhookSecret: []byte("topsecret")}
req := httptest.NewRequest("POST", "/api/webhook/github",
strings.NewReader(`{"ref":"refs/heads/master"}`))
rec := httptest.NewRecorder()
d.handleGitHubWebhook(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestWebhookRejectsBadSignature(t *testing.T) {
d := Deps{WebhookSecret: []byte("topsecret")}
req := httptest.NewRequest("POST", "/api/webhook/github",
strings.NewReader(`{"ref":"refs/heads/master"}`))
req.Header.Set("X-Hub-Signature-256", "sha256=deadbeef")
rec := httptest.NewRecorder()
d.handleGitHubWebhook(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestWebhookValidSignatureReturns204(t *testing.T) {
secret := []byte("topsecret")
body := []byte(`{"ref":"refs/heads/master"}`)
d := Deps{WebhookSecret: secret} // GitSync nil — handler must still 204
req := httptest.NewRequest("POST", "/api/webhook/github", strings.NewReader(string(body)))
req.Header.Set("X-Hub-Signature-256", sign(secret, body))
rec := httptest.NewRecorder()
d.handleGitHubWebhook(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
func TestWebhookIgnoresNonBranchPush(t *testing.T) {
secret := []byte("s")
body := []byte(`{"ref":"refs/heads/some-feature"}`)
d := Deps{WebhookSecret: secret}
req := httptest.NewRequest("POST", "/api/webhook/github", strings.NewReader(string(body)))
req.Header.Set("X-Hub-Signature-256", sign(secret, body))
rec := httptest.NewRecorder()
d.handleGitHubWebhook(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
Run: go test ./internal/server/ -run TestWebhook -v
Expected: FAIL — handleGitHubWebhook / Deps.WebhookSecret undefined.
Create internal/server/handler_webhook.go:
package server
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
)
// webhookMaxBody caps the request body the webhook handler will read.
const webhookMaxBody = 5 << 20 // 5 MiB
// handleGitHubWebhook verifies a GitHub push webhook (HMAC-SHA256 over the
// body against X-Hub-Signature-256) and, when it targets the synced branch,
// asks the gitsync engine to sync. It is a public route — unauthenticated by
// session, protected only by the shared secret. Always responds fast (204);
// the sync runs asynchronously in the engine's background loop.
func (d Deps) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
if len(d.WebhookSecret) == 0 {
// Webhook configured at the route level only when a secret exists, so
// this is defence in depth.
w.WriteHeader(http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, webhookMaxBody))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !validSignature(d.WebhookSecret, body, r.Header.Get("X-Hub-Signature-256")) {
slog.Warn("webhook: signature verification failed")
w.WriteHeader(http.StatusUnauthorized)
return
}
var payload struct {
Ref string `json:"ref"`
}
_ = json.Unmarshal(body, &payload) // a malformed body simply matches no branch
if d.GitSync != nil && payload.Ref == "refs/heads/"+d.GitSync.Branch() {
d.GitSync.RequestSync()
}
w.WriteHeader(http.StatusNoContent)
}
// validSignature constant-time compares the GitHub HMAC header.
func validSignature(secret, body []byte, header string) bool {
if header == "" {
return false
}
m := hmac.New(sha256.New, secret)
m.Write(body)
want := "sha256=" + hex.EncodeToString(m.Sum(nil))
return hmac.Equal([]byte(header), []byte(want))
}
Deps fieldsIn internal/server/server.go, add to the Deps struct (after Realtime):
// GitSync is the git-sync engine. Nil disables webhook-triggered sync,
// makes /api/sync-status return 503, and leaves incorporation local-only.
GitSync *gitsync.Repo
// WebhookSecret is the GitHub webhook HMAC secret. Empty disables the
// webhook route.
WebhookSecret []byte
Add the import "github.com/getorcha/wiki-browser/internal/gitsync" to server.go.
In Mux, after the if d.Auth != nil { ... } block (the webhook is public, like /auth/*), add:
if len(d.WebhookSecret) > 0 {
mux.HandleFunc("POST /api/webhook/github", d.handleGitHubWebhook)
}
Run: go test ./internal/server/ -run TestWebhook -v
Expected: PASS.
git add internal/server/handler_webhook.go internal/server/server.go internal/server/handler_webhook_test.go
git commit -m "server: HMAC-verified GitHub webhook endpoint"
/api/sync-status endpointFiles:
Create: internal/server/handler_sync_status.go
Modify: internal/server/server.go
Test: internal/server/handler_sync_status_test.go
Step 1: Write the failing tests
Create internal/server/handler_sync_status_test.go:
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/getorcha/wiki-browser/internal/alert"
"github.com/getorcha/wiki-browser/internal/gitsync"
)
func TestSyncStatusReturns503WhenDisabled(t *testing.T) {
d := Deps{} // GitSync nil
rec := httptest.NewRecorder()
d.handleSyncStatus(rec, httptest.NewRequest("GET", "/api/sync-status", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestSyncStatusRouteReturns503WhenDisabled(t *testing.T) {
ts := httptest.NewServer(Mux(Deps{
SessionMiddleware: testSessionMiddleware("daniel@getorcha.com", "Daniel", "csrf"),
}))
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/sync-status")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", resp.StatusCode)
}
}
func TestSyncStatusReturnsJSON(t *testing.T) {
root := newServerTestRepo(t) // see helper note below
repo, err := gitsync.New(gitsync.Config{
Root: root, Remote: "origin", Branch: "master",
}, alert.Nop{}, "")
if err != nil {
t.Fatal(err)
}
d := Deps{GitSync: repo}
rec := httptest.NewRecorder()
d.handleSyncStatus(rec, httptest.NewRequest("GET", "/api/sync-status", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if got["state"] != "synced" {
t.Errorf("state = %v, want synced", got["state"])
}
if got["head"] == "" || got["head"] == nil {
t.Error("head missing")
}
}
newServerTestRepo(t) builds a git repo on master with an origin remote and one commit. Add it to internal/server/helpers_test.go (reuse the gitsync package's pattern):
func newServerTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
origin := filepath.Join(dir, "origin.git")
root := filepath.Join(dir, "work")
runGitT(t, dir, "init", "--bare", "-b", "master", origin)
runGitT(t, dir, "clone", origin, root)
runGitT(t, root, "config", "user.name", "t")
runGitT(t, root, "config", "user.email", "t@t")
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
runGitT(t, root, "add", "README.md")
runGitT(t, root, "commit", "-m", "init")
runGitT(t, root, "push", "-u", "origin", "master")
return root
}
func runGitT(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
Add imports os, os/exec, path/filepath to helpers_test.go if absent.
Run: go test ./internal/server/ -run TestSyncStatus -v
Expected: FAIL — handleSyncStatus undefined.
Create internal/server/handler_sync_status.go:
package server
import "net/http"
// handleSyncStatus returns the gitsync engine's current Status as JSON. Used
// by the chrome to render the diverged banner. Session-gated at the route.
func (d Deps) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
if d.GitSync == nil {
writeJSONError(w, http.StatusServiceUnavailable, "git_sync_unavailable")
return
}
writeJSON(w, http.StatusOK, d.GitSync.Status())
}
(writeJSON / writeJSONError are the existing helpers used throughout internal/server.)
In internal/server/server.go Mux, after the /api/stream routes, add this unconditionally:
mux.Handle("GET /api/sync-status",
d.withSession(auth.RequireCollaborator(http.HandlerFunc(d.handleSyncStatus))))
Run: go test ./internal/server/ -run TestSyncStatus -v
Expected: PASS.
git add internal/server/handler_sync_status.go internal/server/server.go internal/server/handler_sync_status_test.go internal/server/helpers_test.go
git commit -m "server: /api/sync-status endpoint"
Files:
Modify: internal/server/handler_proposals.go
Test: internal/server/handler_proposals_test.go
Step 1: Write the failing test
The existing handler_proposals_test.go has incorporation tests with Deps.GitSync == nil. First extract the common setup from TestIncorporate_happyPathDefaultsSubjectReanchorsAndCommits (handler_proposals_test.go:518) into a small fixture helper that returns at least {deps Deps, root string, sourcePath string, incorporateRequest func(*testing.T) *http.Request}. Then add tests that prove the wrap is used when GitSync is set and that an upstream rewrite observed by the wrapper makes the old proposal stale:
func TestIncorporateRoutesThroughGitSyncWhenConfigured(t *testing.T) {
fx := newIncorporateFixture(t)
wrapped := false
fx.deps.GitSync = newFakeGitSync(func() { wrapped = true })
rec := httptest.NewRecorder()
req := fx.incorporateRequest(t) // existing helper: POST .../incorporate
fx.deps.handleIncorporate(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body)
}
if !wrapped {
t.Error("incorporation did not route through gitSync.Incorporate")
}
}
func TestUpstreamRewriteBeforeIncorporateMarksProposalStale(t *testing.T) {
// This is the spec's drift-risk regression: an open Topic/proposal exists,
// then an upstream pull rewrites the same Source before incorporation. The
// gitSync wrapper must run before collab.Incorporate, so freshness/stale
// checks see the rewritten file and reject the old proposal.
fx := newIncorporateFixture(t)
fx.deps.GitSync = newFakeGitSync(func() {
if err := os.WriteFile(filepath.Join(fx.root, fx.sourcePath),
[]byte("# Upstream rewrite\n\nDifferent source.\n"), 0o644); err != nil {
t.Fatal(err)
}
})
rec := httptest.NewRecorder()
req := fx.incorporateRequest(t)
fx.deps.handleIncorporate(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409 stale proposal; body=%s", rec.Code, rec.Body)
}
if !strings.Contains(rec.Body.String(), "stale_proposal") {
t.Fatalf("body = %s, want stale_proposal", rec.Body)
}
}
Important: Deps.GitSync is *gitsync.Repo, a concrete type — it cannot be faked by an interface without a refactor. To keep this testable, introduce a minimal interface in server and have Deps.GitSync use it. Change the Deps.GitSync field type (in server.go, Task 9's addition) from *gitsync.Repo to:
// GitSync is the git-sync engine. Nil disables webhook-triggered sync,
// makes /api/sync-status return 503, and leaves incorporation local-only.
GitSync GitSyncEngine
and add the interface to server.go:
// GitSyncEngine is the gitsync surface the server depends on. *gitsync.Repo
// satisfies it; tests substitute a fake.
type GitSyncEngine interface {
Branch() string
RequestSync()
Status() gitsync.Status
Incorporate(ctx context.Context, fn func() (string, error)) (string, error)
}
Add "context" to server.go imports. *gitsync.Repo already has all four methods, so main.go can still assign a *gitsync.Repo.
Then newFakeGitSync in the test file:
type fakeGitSync struct {
onIncorporate func()
}
func newFakeGitSync(onIncorporate func()) *fakeGitSync {
return &fakeGitSync{onIncorporate: onIncorporate}
}
func (f *fakeGitSync) Branch() string { return "master" }
func (f *fakeGitSync) RequestSync() {}
func (f *fakeGitSync) Status() gitsync.Status { return gitsync.Status{State: gitsync.StateSynced} }
func (f *fakeGitSync) Incorporate(_ context.Context, fn func() (string, error)) (string, error) {
if f.onIncorporate != nil {
f.onIncorporate()
}
return fn()
}
(Test file imports: context, net/http, net/http/httptest, os, path/filepath, strings, testing, and internal/gitsync.)
Run: go test ./internal/server/ -run TestIncorporateRoutes -v
Expected: FAIL — GitSyncEngine / wrap not present.
In internal/server/handler_proposals.go handleIncorporate, replace the direct collab.Incorporate(...) call (the commitSHA, err := collab.Incorporate(...) block) with:
principal, _ := auth.PrincipalFrom(r.Context())
authorName, authorEmail := d.AgentService.AuthorIdentity()
incorporateFn := func() (string, error) {
return collab.Incorporate(d.Collab, collab.IncorporateInput{
RepoRoot: d.Root,
ProposalID: id,
ApproverID: principal.UserID,
ApproverName: principal.DisplayName,
Subject: subject,
Body: req.Body,
AuthorName: authorName,
AuthorEmail: authorEmail,
ReanchorTopicIDs: reanchor,
ChildTopicIDs: childTopicIDs,
})
}
var commitSHA string
if d.GitSync != nil {
commitSHA, err = d.GitSync.Incorporate(r.Context(), incorporateFn)
} else {
commitSHA, err = incorporateFn()
}
if err != nil {
var de *gitsync.DivergedError
if errors.As(err, &de) {
writeJSONError(w, http.StatusConflict, "git_diverged")
return
}
if errors.Is(err, collab.ErrStaleProposal) || errors.Is(err, collab.ErrResolutionInProgress) {
writeJSON(w, http.StatusConflict, map[string]any{"code": "stale_proposal"})
return
}
if errors.Is(err, collab.ErrNoSourceChanges) {
writeJSONError(w, http.StatusUnprocessableEntity, "no_source_changes")
return
}
if errors.Is(err, collab.ErrTopicAlreadyTerminal) {
writeJSONError(w, http.StatusUnprocessableEntity, "topic_terminal")
return
}
writeJSONError(w, http.StatusInternalServerError, "incorporate_failed")
return
}
Add "github.com/getorcha/wiki-browser/internal/gitsync" to the imports of handler_proposals.go. The errors import is already present.
Behaviour note: gitsync.Incorporate runs incorporateFn (the existing collab.Incorporate) after a reconcile, so collab.Incorporate's own base_source_sha stale-check sees the freshly-pulled source. A push failure after a successful commit returns (sha, nil) — the incorporation still succeeds. A *DivergedError from the pre-reconcile is surfaced as HTTP 409 git_diverged.
Run: go test ./internal/server/ -v
Expected: PASS (all server tests, including existing incorporation tests with GitSync == nil).
git add internal/server/handler_proposals.go internal/server/server.go internal/server/handler_proposals_test.go
git commit -m "server: route incorporation through gitsync (fetch then commit then push)"
Files:
Modify: internal/agent/service.go
Test: internal/agent/service_test.go
Step 1: Write the failing test
Add to internal/agent/service_test.go. The existing tests build a Service with a fakeRunner (see internal/agent/fake_runner.go); reuse that. The recording notifier:
type recordingAlerter struct {
mu sync.Mutex
msgs []string
}
func (a *recordingAlerter) Send(msg string) {
a.mu.Lock()
defer a.mu.Unlock()
a.msgs = append(a.msgs, msg)
}
func (a *recordingAlerter) count() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.msgs)
}
func TestThreeConsecutiveFailuresRaiseOneAlert(t *testing.T) {
alerter := &recordingAlerter{}
// Build a Service whose Runner always fails. Mirror the existing
// service-test setup (store, ServiceConfig); add Alerter + a
// failing fake runner.
svc := newTestService(t, testServiceOpts{
runner: failingRunner{}, // RunResult{Err: errors.New("boom")}
alerter: alerter,
})
defer svc.Stop()
for i := 0; i < 3; i++ {
runOneIncorporateJobToCompletion(t, svc) // submit + wait for terminal status
}
if got := alerter.count(); got != 1 {
t.Fatalf("alerts = %d, want exactly 1 after 3 failures", got)
}
}
func TestSuccessResetsFailureCounter(t *testing.T) {
alerter := &recordingAlerter{}
runner := &scriptedRunner{} // succeeds/fails per call; see note
svc := newTestService(t, testServiceOpts{runner: runner, alerter: alerter})
defer svc.Stop()
runner.next("fail")
runOneIncorporateJobToCompletion(t, svc)
runner.next("fail")
runOneIncorporateJobToCompletion(t, svc)
runner.next("succeed")
runOneIncorporateJobToCompletion(t, svc)
runner.next("fail")
runOneIncorporateJobToCompletion(t, svc)
if got := alerter.count(); got != 0 {
t.Fatalf("alerts = %d, want 0 (success reset the counter)", got)
}
}
The test file must provide newTestService / failingRunner / scriptedRunner / runOneIncorporateJobToCompletion consistent with the existing service_test.go harness — read that file and reuse its fixtures rather than inventing new ones. The behavioural assertions (1 alert after 3 failures; reset on success) are the contract.
Run: go test ./internal/agent/ -run 'TestThreeConsecutive|TestSuccessResets' -v
Expected: FAIL — Alerter / failure counting absent.
In internal/agent/service.go:
Add to the imports: "github.com/getorcha/wiki-browser/internal/alert".
Add to ServiceConfig (after Realtime):
// Alerter receives an operator notification when the Agent runtime looks
// broken (a run of consecutive job failures). Nil ⇒ no alerting.
Alerter alert.Notifier
Add to the Service struct's mutex-guarded block (after inflight/sourceSem/globalSem):
consecutiveFailures int // guarded by mu
agentFailAlerted bool // guarded by mu
Add a constant near the top of the file:
// agentFailureAlertThreshold is the run of consecutive job failures that
// signals the Agent runtime itself is broken (expired claude login, missing
// binary, API down).
const agentFailureAlertThreshold = 3
In NewService, default a nil alerter so callers may omit it:
if cfg.Alerter == nil {
cfg.Alerter = alert.Nop{}
}
(Place this next to the existing MaxConcurrentJobs defaulting.)
In run(), after the successful CompleteJob block — i.e. immediately before if s.cfg.Realtime != nil { — add:
s.recordJobOutcome(status)
Add the method:
// recordJobOutcome tracks consecutive job failures and edge-triggers operator
// alerts: a run of agentFailureAlertThreshold failures raises one alert; the
// first subsequent success raises one recovery alert.
func (s *Service) recordJobOutcome(status string) {
s.mu.Lock()
if status == "succeeded" {
recovered := s.agentFailAlerted
s.consecutiveFailures = 0
s.agentFailAlerted = false
s.mu.Unlock()
if recovered {
s.cfg.Alerter.Send("wiki-browser: Agent runtime RECOVERED — jobs succeeding again.")
}
return
}
s.consecutiveFailures++
fire := s.consecutiveFailures >= agentFailureAlertThreshold && !s.agentFailAlerted
if fire {
s.agentFailAlerted = true
}
n := s.consecutiveFailures
s.mu.Unlock()
if fire {
s.cfg.Alerter.Send(fmt.Sprintf(
"wiki-browser: Agent runtime FAILING — %d consecutive job failures. "+
"Check the claude CLI login / binary / API.", n))
}
}
(fmt is already imported in service.go.)
Run: go test ./internal/agent/ -v
Expected: PASS.
git add internal/agent/service.go internal/agent/service_test.go
git commit -m "agent: alert on sustained consecutive job failures"
main.goFiles:
Modify: cmd/wiki-browser/main.go
Test: cmd/wiki-browser/main_test.go
Step 1: Write the failing test
main.go's run() is hard to unit-test wholesale. Test the one extractable piece: a buildNotifier helper that turns the optional *config.Alert into an alert.Notifier. Add to cmd/wiki-browser/main_test.go:
func TestBuildNotifier(t *testing.T) {
// No alert block → Nop.
if _, ok := buildNotifier(nil).(alert.Nop); !ok {
t.Error("nil Alert config should yield alert.Nop")
}
// Alert block with a URL file → Slack.
dir := t.TempDir()
f := filepath.Join(dir, "url")
if err := os.WriteFile(f, []byte("https://hooks.slack.com/x\n"), 0o600); err != nil {
t.Fatal(err)
}
n := buildNotifier(&config.Alert{SlackWebhookURLFile: f})
if _, ok := n.(*alert.Slack); !ok {
t.Errorf("Alert config should yield *alert.Slack, got %T", n)
}
}
(Imports: os, path/filepath, testing, internal/alert, internal/config.)
Run: go test ./cmd/wiki-browser/ -run TestBuildNotifier -v
Expected: FAIL — buildNotifier undefined.
buildNotifier and wire the engineIn cmd/wiki-browser/main.go, add the imports "time", "github.com/getorcha/wiki-browser/internal/alert", and "github.com/getorcha/wiki-browser/internal/gitsync" if they are not already present.
Add the helper:
// buildNotifier turns the optional alert config into an alert.Notifier.
// A nil config — or an unreadable URL file — yields a no-op notifier so a
// misconfigured alert channel never blocks startup.
func buildNotifier(cfg *config.Alert) alert.Notifier {
if cfg == nil {
return alert.Nop{}
}
raw, err := os.ReadFile(cfg.SlackWebhookURLFile)
if err != nil {
slog.Warn("alert: cannot read slack webhook url; alerting disabled", "err", err)
return alert.Nop{}
}
return alert.NewSlack(strings.TrimSpace(string(raw)))
}
Add a helper for the threshold passed into gitsync.Config:
func alertFailThreshold(cfg *config.Alert) time.Duration {
if cfg == nil || cfg.FailThreshold == 0 {
return 15 * time.Minute
}
return cfg.FailThreshold
}
In run(), the wiring goes in this order (the spec's startup sequence):
(a) Right after slog.Info("config loaded", ...) and before walker.New, construct the engine and run startup catch-up:
notifier := buildNotifier(cfg.Alert)
var gitSync *gitsync.Repo
var webhookSecret []byte
if cfg.Git != nil {
secretRaw, err := os.ReadFile(cfg.Git.WebhookSecretFile)
if err != nil {
return fmt.Errorf("read git.webhook_secret_file: %w", err)
}
webhookSecret = []byte(strings.TrimSpace(string(secretRaw)))
gitSync, err = gitsync.New(gitsync.Config{
Root: cfg.Root,
Remote: cfg.Git.Remote,
Branch: cfg.Git.Branch,
PollInterval: cfg.Git.PollInterval,
FailThreshold: alertFailThreshold(cfg.Alert),
Extensions: cfg.Extensions,
}, notifier, cfg.Auth.PublicBaseURL)
if err != nil {
return fmt.Errorf("gitsync: %w", err)
}
// Startup catch-up: fetch+ff anything missed while offline, then flush
// any unpushed incorporation commit. Non-fatal — a network-less boot
// must still serve. Runs before walker.New so the initial scan and
// collab.Recover both see the up-to-date tree.
if _, err := gitSync.Sync(rootCtx); err != nil {
slog.Warn("gitsync: startup sync failed", "err", err)
}
if err := gitSync.Push(rootCtx); err != nil {
slog.Warn("gitsync: startup push failed", "err", err)
}
}
Note on rootCtx: it is currently created later in run() (signal.NotifyContext). Move that rootCtx, cancel := signal.NotifyContext(...) line up so it precedes this block, and keep its defer cancel().
(b) After idx is opened and renderCache wired (the existing idx.SetCache(renderCache) line), register the runtime reindex hook:
if gitSync != nil {
gitSync.SetOnSync(func(res gitsync.SyncResult) {
if err := w.Rescan(); err != nil {
slog.Warn("gitsync: walker rescan failed", "err", err)
}
for _, rel := range res.ChangedPaths {
abs := filepath.Join(cfg.Root, rel)
if w.Has(rel) {
if err := idx.Reindex(abs); err != nil {
slog.Warn("gitsync: reindex failed", "path", rel, "err", err)
}
} else {
if err := idx.Remove(abs); err != nil {
slog.Warn("gitsync: remove failed", "path", rel, "err", err)
}
}
}
})
}
(c) Add Alerter: notifier to the existing agent.NewService(agent.ServiceConfig{...}) call.
(d) Add to the existing server.Mux(server.Deps{...}) call:
GitSync: gitSync,
WebhookSecret: webhookSecret,
(gitSync is *gitsync.Repo; it satisfies server.GitSyncEngine. When cfg.Git == nil, gitSync is a nil *gitsync.Repo — assign it directly; a nil pointer in an interface field would be non-nil-interface, so guard: if gitSync != nil { deps.GitSync = gitSync }. Build the Deps value, then conditionally set the field, rather than inline.)
Concretely, change the Deps construction so GitSync is only set when non-nil:
deps := server.Deps{
Title: cfg.Title,
// ... all existing fields ...
WebhookSecret: webhookSecret,
}
if gitSync != nil {
deps.GitSync = gitSync
}
mux := server.Mux(deps)
(e) After the server starts listening (alongside the other background goroutines), start the engine loop:
if gitSync != nil {
go gitSync.Run(rootCtx)
}
Run: go test ./cmd/wiki-browser/ -v
Expected: PASS.
Run: go build ./...
Expected: builds clean.
Run: go test ./...
Expected: PASS.
git add cmd/wiki-browser/main.go cmd/wiki-browser/main_test.go
git commit -m "main: wire gitsync engine, startup catch-up, alert notifier"
diverged banner in the chromeFiles:
Modify: internal/server/templates/shell.html
Modify: internal/server/static/chrome.js
Modify: internal/server/static/chrome.css
Test: manual (playwright-cli) — see Step 4
Step 1: Add the banner element
In internal/server/templates/shell.html, immediately after the opening <body ...> tag's > and before <header class="wb-topbar">, add:
{{ if .Authenticated }}
<div id="wb-sync-banner" class="wb-sync-banner" role="alert" hidden></div>
{{ end }}
Append to internal/server/static/chrome.css:
/* Sync-status banner — shown only when git sync needs a human. */
.wb-sync-banner {
background: #7c2d12;
color: #fef3c7;
font-size: 13px;
font-weight: 600;
text-align: center;
padding: 6px 12px;
}
.wb-sync-banner[hidden] { display: none; }
Append to internal/server/static/chrome.js (it is an ES module; this self-contained block has no dependency on the rest of the file):
// --- git sync status banner -------------------------------------------------
// Polls /api/sync-status; shows a banner only when the engine is `diverged`,
// which needs a human to resolve the merge on the host. The Slack alert is the
// primary notification — this is the in-app backstop.
(function syncStatusBanner() {
const banner = document.getElementById("wb-sync-banner");
if (!banner) return; // unauthenticated shell
async function poll() {
try {
const resp = await fetch("/api/sync-status", { credentials: "same-origin" });
if (!resp.ok) return; // 503 when sync is disabled — leave banner hidden
const status = await resp.json();
if (status.state === "diverged") {
banner.textContent =
"Git sync diverged — incorporations are not reaching the shared repo. " +
"Manual resolution needed on the server.";
banner.hidden = false;
} else {
banner.hidden = true;
}
} catch (_) {
/* network blip — keep the last banner state */
}
}
poll();
setInterval(poll, 60000);
})();
Templates and static assets are embedded via embed.FS, so rebuild and restart before checking. Per wiki-browser/CLAUDE.md:
make build
pkill -f 'dist/wiki-browser' || true
nohup ./dist/wiki-browser -config=wiki-browser.dev.yaml >/tmp/wb.log 2>&1 &
disown
playwright-cli open --browser=chromium http://localhost:8080/doc/README.md
wiki-browser.dev.yaml has no git: block, so /api/sync-status returns 503 and the banner stays hidden — confirm the page renders normally with no banner. Then confirm the banner element exists and is hidden:
playwright-cli eval "() => { const b = document.getElementById('wb-sync-banner'); return b ? b.hidden : 'missing'; }"
Expected: true (element present, hidden). A full diverged-state visual check happens in Task 15's deployment verification, where a real git: block exists.
git add internal/server/templates/shell.html internal/server/static/chrome.js internal/server/static/chrome.css
git commit -m "chrome: diverged sync-status banner"
Files:
Modify: deploy/wiki-browser.service
Modify: Makefile
Modify: wiki-browser.example.yaml
Test: build + verification commands in Step 4
Step 1: Update the systemd unit
Replace the contents of deploy/wiki-browser.service with:
# deploy/wiki-browser.service
[Unit]
Description=wiki-browser
Wants=network-online.target
After=network-online.target
[Service]
# Runs as the unprivileged karn account. The clone, /srv/wiki-browser, the
# SSH deploy key and every secret file are owned by it.
User=karn
WorkingDirectory=/srv/wiki-browser
ExecStart=/srv/wiki-browser/bin/wiki-browser -config=/srv/orcha/wiki-browser/wiki-browser.yaml
# EnvironmentFile supplies PATH (so the spawned `claude` and node resolve) and,
# only if the API-key route is chosen for Agent auth, ANTHROPIC_API_KEY.
EnvironmentFile=/srv/wiki-browser/wiki-browser.env
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
deploy Makefile targetAppend to Makefile (and add deploy to the .PHONY line):
# Cross-build and ship both binaries to the Pi, then restart the service.
# Override the host: make deploy PI_HOST=karn@raspberrypi.local
PI_HOST ?= karn@wiki-pi
deploy: build-arm64
scp dist/wiki-browser-arm64 $(PI_HOST):/srv/wiki-browser/bin/wiki-browser
scp dist/wb-agent-arm64 $(PI_HOST):/srv/wiki-browser/bin/wb-agent
ssh $(PI_HOST) sudo systemctl restart wiki-browser
Update the .PHONY line to: .PHONY: build build-arm64 test run lint clean deploy
In wiki-browser.example.yaml, append before the agent: block (or at the end):
# git: enables the sync engine. Omit the whole block for local-only use
# (development, dev mode) — wiki-browser then never fetches or pushes.
git:
remote: "origin" # default
branch: "master" # default
# GitHub webhook HMAC secret. Path, not value — the parsed config holds only
# the path, so a stray log of the config cannot leak the secret.
webhook_secret_file: "/srv/wiki-browser/secrets/github-webhook-secret"
# 0 = webhook-only (the default). Set e.g. "10m" for a safety-net poll that
# self-heals a missed webhook.
poll_interval: "0"
# alert: enables Slack notifications (diverged state, sustained sync/push
# failure, sustained Agent-job failure). Omit for no alerting.
alert:
slack_webhook_url_file: "/srv/wiki-browser/secrets/slack-webhook-url"
# Alert if sync/push fails continuously for this long.
fail_threshold: "15m"
Run: make build
Expected: both binaries build clean (proves the config example is not parsed, but confirms the tree compiles).
Run: go test ./...
Expected: PASS — full suite green.
Validate the systemd unit syntax (does not require the service to be installed):
Run: systemd-analyze verify deploy/wiki-browser.service (ignore the Unit ... not found note for network-online.target if run off-Pi; there must be no syntax errors).
git add deploy/wiki-browser.service Makefile wiki-browser.example.yaml
git commit -m "deploy: karn service unit, make deploy target, git/alert example config"
Spec coverage — every Design subsection maps to a task:
| Spec section | Task(s) |
|---|---|
| Pi repo layout | 15 (config paths, systemd WorkingDirectory/ExecStart) |
gitsync engine (reconcile/Sync/Push/Incorporate/Status/SyncResult/State) |
4–8 |
| Webhook endpoint (HMAC, public route, coalesced async) | 9 (+ RequestSync in 8) |
| Incorporation push integration | 11 |
Conflict & drift handling (DivergedError, rebase retry, stale-check) |
5, 7, 11 |
Startup sequence (catch-up before walker.New/Recover) |
13 |
Configuration (git:/alert: blocks, validation) |
1 |
Provisioning (systemd, secrets-as-files, make deploy) |
15 |
| Operations (sync-status endpoint, observability) | 10 |
| Failure modes (push-pending retry, threshold) | 8 |
| Alerting (Slack, diverged w/ doc link, sustained failure, agent failure) | 2, 6, 8, 12 |
walker.Rescan (deterministic refresh) |
3 |
CD seams (SyncResult returned, Status.Head, sole-mutator) |
4, 6 (types); 13 (single engine instance) |
Backups (sqlite3 .backup timer) and the GitHub-webhook / deploy-key / NPM provisioning steps are operator runbook actions, not code — they are spec §Provisioning/§Operations items the operator performs, correctly excluded from the implementation tasks.
Type consistency — checked across tasks: gitsync.Config{Root,Remote,Branch,PollInterval,FailThreshold,Extensions}, gitsync.Status{State,Head,Ahead,LastSyncAt,LastError}, gitsync.SyncResult{OldHead,NewHead,ChangedPaths,Rebased}, gitsync.State constants, gitsync.DivergedError{Paths}, gitsync.New(Config, alert.Notifier, string), Repo.{Sync,Push,Incorporate,Status,Branch,SetOnSync,RequestSync,Run}, alert.Notifier.Send(string), server.GitSyncEngine (the four methods *gitsync.Repo provides), config.Git/config.Alert{SlackWebhookURLFile,FailThreshold} pointers, agent.ServiceConfig.Alerter. All consistent.
Placeholder scan — no TBD/TODO/"implement later". Task 11 explicitly extracts newIncorporateFixture from existing incorporation tests before using it; Task 12 references the existing agent service_test.go harness and gives the new behavioural assertions in full.
Plan complete and saved to docs/superpowers/plans/2026-05-21-deployment-git-sync.md. Two execution options:
Which approach?