Architecture
1. Stack (see ADRs)
| Concern | Choice | Why |
|---|---|---|
| Language | Go ≥ 1.26 | Static binary, tiny footprint, great stdlib (net/http, slog), easy cross-compile (ADR-0001) |
| Git engine | System git CLI (≥ 2.34) via hardened wrapper |
Full fidelity, LFS/partial clone/credentials; go-git lacks features (ADR-0002) |
| State | SQLite via pure-Go driver (modernc.org/sqlite), WAL |
Queryable, crash-safe, no CGO (ADR-0003) |
| Config | TOML + JSON Schema | Human-editable, comments (ADR-0004) |
| UI | Server-rendered Go templates + htmx + a little vanilla JS, embedded via embed |
No Node build, tiny, auditable (ADR-0005) |
| Secrets | zalando/go-keyring (+ encrypted file fallback) |
OS-native stores (ADR-0006) |
| Releases | GoReleaser + cosign + SBOM | (ADR-0007) |
2. Component view
┌──────────────── repo-keeper (single process) ────────────────┐
CLI ──────▶│ cmd/repo-keeper ─▶ app (wiring, lifecycle, single-instance)│
Browser ──▶│ ui/http (127.0.0.1, token, CSRF) ─▶ api service layer │
│ │
│ scheduler ─▶ job queue ─▶ worker pool │
│ │ │ │
│ ratelimit (per host) │ │
│ ▼ ▼ │
│ provider/{github,gitlab,bitbucket,azdo,gitea} gitx │
│ (REST, ETag, paging) (git CLI wrapper) │
│ │ │ │
│ secrets (keyring) fs (clone root) │
│ └──────── store (SQLite) ───────── │
└────────────────────────────────────────────────────────────┘
Packages (internal/)
| Package | Responsibility |
|---|---|
config |
Strict TOML load/validate (unknown keys rejected, 5 min interval floor, quiet hours), per-repo resolution; polled hot-reload lives in daemon |
secrets |
Keyring abstraction, redaction types (secrets.Token never prints) |
provider |
Provider interface + registry; one subpackage per platform |
httpx |
Only outbound HTTP path: required UA, https-only (loopback exempt for tests), TLS ≥1.2 never unverified, redirect downgrade refused, retries for GET/HEAD, in-memory ETag cache |
ratelimit |
Per-host token bucket + concurrency cap, Retry-After/X-RateLimit-*/RateLimit-* parsing, cooldowns, jittered exponential backoff, circuit breaker, quota tracking (WaitError lets callers defer instead of block) |
gitx |
Run git safely: env sanitising, hooks off, askpass, timeouts, output parsing |
syncer |
Per-repo flow: lock → safety checks → ls-remote → fetch → fast-forward → cleanup (named syncer to avoid clashing with stdlib sync) |
audit |
Append-only journal (JSONL, fsynced) of deletions/restores/blocks; deliberately stays a flat file (ADR-0014) |
paths |
Per-user state locations |
gitxtest |
Test fixtures: isolated bare origin + clones |
cleanup |
Pure safety predicate + executor + trash/restore |
sched |
Tick loop, bounded worker pool, pure Next() retry/backoff policy, quiet hours, manual trigger; run-once-after-sleep falls out of rescheduling from now |
store |
SQLite (WAL, forward-only migrations, schema-version guard): repos, runs (50/repo), events (ring of 2000) |
instance |
Single-instance OS file lock (flock / exclusive-share handle), released automatically on crash |
daemon |
Wiring: lock → store → config → limiter → scheduler; per-repo job (limiter permit → syncer → classify → record); config poll/reload |
ui |
HTTP handlers, templates, static assets, session/CSRF |
tray + cmd/repo-keeper-tray |
Optional tray helper: status model, generated icons, API client (control token); no state, no secrets |
control |
Types shared by daemon, CLI and tray (runtime file, status) |
svc |
OS autostart integration (systemd/launchd/Task Scheduler) |
obs |
slog setup, redaction, in-memory ring buffer for UI log view |
Dependency rule: syncer and cleanup depend on interfaces (Provider, Git, Clock, Store) so all logic is unit-testable with fakes. ui depends only on svc-level service interfaces, never on gitx directly.
3. Provider interface
type Provider interface {
Kind() Kind
// ListRepos pages through accessible repos; honours ETag via opts.
ListRepos(ctx context.Context, opts ListOpts) iter.Seq2[Repo, error]
// DefaultBranch returns the authoritative default branch.
DefaultBranch(ctx context.Context, r RepoRef) (string, error)
// MergedRefs reports branches whose PR/MR is merged (squash/rebase aware).
MergedRefs(ctx context.Context, r RepoRef, branches []string) (map[string]MergeInfo, error)
// Limits exposes the last-seen quota for UI and budgeting.
Limits() Quota
// GitUsername is the user git presents with the token over HTTPS.
GitUsername() string
// CheckAuth validates the credential and its scopes.
CheckAuth(ctx context.Context) (AuthStatus, error)
}
Platforms register themselves (provider.Register) and are enabled by one import in internal/provider/all. Every provider must pass the shared contract suite in providertest (complete paged listing with nested namespaces, correct flags, ErrAuth without echoing the token, merged-PR lookup that ignores forks and unmerged PRs, cancellation). Include/exclude globs: * within a level, ** across levels.
4. Data model (SQLite)
- (M3)
accounts(id, provider, base_url, display_name, cred_ref, status, last_check_at) repos(id, path, remote, host, interval_s, active, default_branch, digest, next_sync_ms, last_sync_ms, last_status/reason/error/ff, failures, needs_attention)(provider columns arrive in M3)runs(id, repo_id, started_ms, finished_ms, status, reason, ff, fetched, error, detail)events(id, ts, level, repo_id, code, message)(ring-capped)- Trash lives in git itself (
refs/repo-keeper/trash/<unix>/<branch>); the audit journal isaudit.jsonl, see ADR-0014 - Schema version via
PRAGMA user_version
No secrets in the DB. Migrations are forward-only, embedded, tested.
5. Sync algorithm (per repo)
acquire repo lock (file lock in .git/repo-keeper.lock) ── held? skip
if not cloned: clone (partial/shallow per rules) ─▶ done
preflight: refuse if in-progress op (rebase/merge/cherry-pick/bisect), index.lock present
detect default branch D:
remote HEAD via `ls-remote --symref origin HEAD` (cheap, no API)
fallback provider.DefaultBranch(); fallback origin/HEAD; fallback main|master
if changed vs stored → update refs/remotes/origin/HEAD, emit event
ls-remote refs unchanged since last run? ─▶ skip fetch (FR-R3)
git fetch --prune --no-tags(configurable) origin
fast-forward D:
if D checked out here:
clean? ─▶ merge --ff-only origin/D else skip("dirty")
elif D exists locally and is ancestor of origin/D:
update-ref D (via `fetch origin D:D`, refuses non-FF)
else skip("diverged") + warn
cleanup (if enabled): candidates = locals − {current, D, worktrees, protected, young}
for b in candidates: safe(b) ⇒ trash ref + delete (or list in review)
record run, schedule next (interval ± jitter), release lock
Cleanup safety predicate (pure function; 100 % tested, property-tested)
delete(b) = !protected ∧ !default ∧ !current ∧ checkedOut=No ∧ tipAge ≥ minAge ∧ (hasUpstream ∨ allowNeverPushed) ∧ (merged(b, D) ∨ (upstreamGone ∧ (providerMergedTip ∨ patchEquivalent)))
- Every input is tri-state (
Yes/No/Unknown);Unknownalways means skip (fail closed). Zero-valued facts never delete. hasUpstreamguards fresh/empty branches: a branch created frommainlooks "merged" but was never pushed, so it needs--allow-never-pushed.providerMergedTipmeans the provider reports a merged PR whose head SHA equals the local tip, so late local commits are never lost to a squash merge.patchEquivalent(git cherry) only recognises single-commit squashes; multi-commit squashes need provider data (M3).- Before deleting, the executor re-reads the tip, re-checks
UniqueCommits == 0for merged branches, writes the trash ref, journals first (write-ahead; journal failure aborts), then deletes withupdate-ref -d <ref> <expected-sha>. - Dirty working tree (including untracked files), in-progress operations, or a held lock block all deletions in that repo; this is journaled as
blocked. - The predicate is verified by an exhaustive enumeration of all fact combinations (not sampling) in
internal/cleanup/decide_test.go.
6. Scheduling & rate limiting
- Scheduler ticks every 30 s, enqueues due repos (
next_sync_at ≤ now), ordered by staleness; jitter ±20 %. - Worker pool:
general.concurrency(default 4); per-host semaphoregeneral.per_host(default 2). A job that must wait more than 30 s on a host is deferred (not blocked) and rescheduled at the cooldown end. - Every outbound call (API through
httpx, git network ops through the daemon job) holds aratelimitpermit for its host. Git failures are classified (rate-limited / auth / network) from stderr and fed back. - Response handling: parse
Retry-After,X-RateLimit-Remaining/Reset,RateLimit-*(IETF draft), GitHub secondary limits (403/429 + message) → set host cooldown; exponential backoff (base 30 s, cap 1 h, full jitter); circuit opens after 5 consecutive failures, half-open probe. - Discovery uses ETags; unchanged = 304 (does not count against quota on GitHub).
- Clock: monotonic time for cooldowns; wall-clock jumps (sleep/resume) handled once.
7. Web UI
Pages: Dashboard (repo table, filters, bulk "sync now"), Repo detail (history, branches, cleanup candidates with reasons), Accounts (add via device flow/token, health, scopes), Rules & schedule, Cleanup review (approve/undo, trash), Rate limits, Logs, Settings.
Startup: the daemon binds 127.0.0.1:7878 (configurable); if taken it binds port 0 (OS-assigned free port) and records the real address in $XDG_RUNTIME_DIR/repo-keeper/ui.json (0600). repo-keeper ui reads it, requests a one-time login URL (TTL 60 s, single use) from the daemon over the local API and opens it in the browser; the URL exchanges for an HttpOnly, SameSite=Strict session cookie. Every mutating request needs a CSRF token and Origin/Host match (blocks DNS rebinding); CSP default-src 'self'; no inline scripts; no external assets/CDNs. Headless: repo-keeper ui --print-url (use with SSH port-forward).
Tray (optional): separate binary/subcommand repo-keeper-tray, a thin client of the same local API (status poll/SSE). On Linux it speaks StatusNotifierItem over D-Bus (pure Go, no CGO; works on KDE, GNOME with the AppIndicator extension, waybar/sway trays); on Windows it uses the notification area via Win32 syscalls. macOS later. Keeping it out of the daemon keeps the daemon headless-friendly, small and free of GUI dependencies; if the tray crashes or is absent nothing else changes.
8. Config sketch
[general]
root = "~/code"
layout = "{provider}/{namespace}/{repo}" # namespace = 1..N levels (group/subgroup, org/project)
interval = "30m" # min 5m enforced
quiet_hours = "23:00-07:00"
[cleanup]
mode = "dry-run" # off | dry-run | auto (per repo overridable; dirty tree always blocks)
min_age = "7d"
protected = ["develop", "release/*"]
[[account]]
name = "work-gh"
provider = "github"
base_url = "https://api.github.com"
auth = "oauth-device" # token | oauth-device | app | ssh
include = ["my-org/*"]
exclude = ["*/archive-*"]
9. Error handling & observability
Typed errors (ErrDirty, ErrDiverged, ErrRateLimited{RetryAfter}, ErrAuth) map to run outcomes and UI badges. Logs via slog with redaction handler; /healthz (local) and repo-keeper status --json for scripting. Optional Prometheus /metrics on the local socket (C).
10. Cross-platform notes
Windows: long paths, case-insensitive collisions (detect, warn), git.exe discovery, Credential Manager. macOS: Keychain, LaunchAgent, notarization. Linux: Secret Service (fallback file for headless), systemd user unit; NixOS/Nix flake provided.