Pi Deployment & Git-Sync Implementation Plan

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


File Structure

New files:

Modified files:

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.


Task 1: Config — git: and alert: blocks

Files:

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"

Task 2: internal/alert — Slack notifier

Files:

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"

Task 3: walker.Rescan()

Files:

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.

In 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"

Task 4: gitsync — git exec helper + test scaffolding + New

Files:

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.)

Create 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.

package 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
}
// 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"

Task 5: gitsyncreconcile and DivergedError

Files:

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.

Add 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)"

Task 6: gitsync — state transitions, alerts, and Sync

Files:

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.

Add 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"

Task 7: gitsyncPush and Incorporate

Files:

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.

Add 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"

Task 8: gitsync — background loop, retry, failure-threshold alert

Files:

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.

package 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"

Task 9: GitHub webhook endpoint

Files:

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))
}

In 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"

Task 10: /api/sync-status endpoint

Files:

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"

Task 11: Incorporation push integration

Files:

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)"

Task 12: Agent consecutive-failure alert

Files:

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"

Task 13: Startup wiring in main.go

Files:

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.

In 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"

Task 14: diverged banner in the chrome

Files:

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"

Task 15: Deployment artifacts

Files:

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

Append 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"

Self-Review

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.


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-21-deployment-git-sync.md. Two execution options:

  1. Subagent-Driven (recommended) — a fresh subagent per task, two-stage review between tasks, fast iteration.
  2. Inline Execution — execute tasks in this session with checkpoints for review.

Which approach?