repo-keeper v0.1.0-beta.7 GitHub

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)

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

6. Scheduling & rate limiting

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.