7.1 KiB
AGENTS.md
Notes for anyone (human or agent) changing this repository. User-facing docs live in README.md; this file is about the code.
What this is
A daemon that mirrors git repositories from src to dst on a timer, driven by a
hot-reloadable TOML file. Go 1.24, one third-party dependency
(github.com/BurntSushi/toml), ~1500 lines of non-test code.
Design goals, in priority order: do not corrupt or delete anything on dst, stay small in memory, stay simple. When a change trades any of these for convenience, it is probably the wrong change.
Commands
go build ./...
go test ./... # includes end-to-end tests that shell out to real git
go test -race -count=1 ./... # what CI runs
go vet ./...
gofmt -l . # must print nothing; CI fails otherwise
go run . -config config.example.toml -check # needs SRC_TOKEN set to anything
The tests need a git binary on PATH. They create real repositories under
t.TempDir() and never touch the network.
Layout
main.go flags, logger, signal handling, config file watcher
signal_unix.go/_other.go build-tagged SIGHUP wiring (no-op on non-unix)
internal/config/ TOML parsing, validation, defaulting → []Job
internal/gitx/ hermetic wrapper around the git CLI
proc_unix.go/_other.go build-tagged process-group creation and kill
internal/syncer/ one sync cycle for one repository
internal/manager/ per-repo goroutines, reload diffing, backoff
http.go /healthz /readyz /status /metrics
Dependency direction is strictly main → manager → syncer → gitx → config.
Nothing under internal/ imports manager.
Invariants
These are load-bearing. Breaking one is a bug even if the tests still pass.
-
config.Jobis fully resolved. All defaulting, env expansion and validation happen inconfig.resolve. Downstream code never asks "was this field set?" — it reads the value. This is also what makes reload diffing work:Applydecides whether a repo changed withreflect.DeepEqualonJob, so aJobmust contain no pointers, maps, funcs, or timestamps, and must be deterministic for identical input. -
Repository URLs never reach disk. They are passed as command-line arguments to
git fetch/git push/git ls-remote, never written into.git/configviagit remote add. URLs may embed tokens; mirror directories may outlive the process. -
Secrets are scrubbed from every string that escapes.
gitx.Runruns both its log lines and git's output throughscrubusingOptions.Secrets, and URLs throughRedactURL. Any new code path that logs or wraps git output must do the same. There is a test asserting a token never appears in an error. -
Every git invocation goes through
gitx.Run. It supplies the isolatedHOME, disables system/global gitconfig, drops inheritedGIT_SSH_COMMANDand friends, setsGIT_TERMINAL_PROMPT=0, and puts the child in its own process group so a timeout killssshtoo. Callingexec.Command("git", ...)directly anywhere else reintroduces all of those problems. -
The syncer holds no state between cycles. Every cycle re-derives truth from
ls-remoteon both ends. Do not add a state file, a "last synced SHA" cache, or an in-memorymap[repo]refsshortcut — the self-healing behaviour (recovering from external force-pushes and partial pushes) comes entirely from not trusting anything remembered. -
An empty source never empties a destination unless
allow_empty = true. The guard is insyncer.Sync; keep it before the push, not inside it. -
One goroutine per mirror directory, ever.
Applycancels the goroutine being replaced and passes itsdonechannel to the successor aswaitFor, so the successor blocks until the predecessor has exited. Two goroutines runninggitin the same bare repo will corrupt it. -
Applymust not block. It is called from the config watcher; a repo may be 25 minutes into a 30-minute sync. Hence thewaitForhandoff above rather than waiting inline.
Gotchas discovered the hard way
-
limitedWriter.Writemust returnlen(p), not the number of bytes it chose to keep.os/exectreats a short write as an error and would abort an otherwise healthy git run once output exceeded the cap. -
signal.Notifywith an empty slice subscribes to every signal. ThereloadSignals()call site is guarded with a length check for the non-unix build where the slice is empty. -
Git refuses to delete the branch that HEAD points at (
receive.denyDeleteCurrent). This shows up when pruning a renamed default branch; GitHub behaves the same way. It is server-side behaviour, not something to work around in the client. The syncer test fixture setsreceive.denyDeleteCurrent ignoreon its bare dst for exactly this reason. Documented in the README's troubleshooting section. -
url.UserPasswordpercent-encodes.RedactURLuses the literal stringredactedas the placeholder;***came back as%2A%2A%2A. -
Deploy keys mounted read-only are usually 0644 and ssh rejects them.
gitx.usableKeystages a 0600 copy in the per-sync temp dir. Do not "fix" this by chmod'ing the original — the operator often cannot make it writable. -
IdentitiesOnly=yesis required. Without it ssh offers every key the agent knows about and GitHub closes the connection with "too many authentication failures" before reaching the right one. -
expandEnvdeliberately only understands${VAR}. Bare$VARis left alone because$is common in passwords, and an undefined variable is a hard error rather than an empty expansion, which would otherwise produce a URL that fails in a confusing way much later.
Testing conventions
internal/syncer/syncer_test.go has a harness that builds real source and bare
destination repositories and drives Sync against them. New sync behaviour
belongs there rather than in a mock — the interesting bugs in this program are
all in how git actually behaves. Existing cases cover first sync, no-op cycles,
new commits, prune, force-push after a rewrite, repairing drift on dst, the
empty-source refusal, ref filtering, unreachable sources, and timeouts.
Manager tests assert reload semantics: unchanged repos keep running, changed
repos restart, removed repos stop. They run under -race; the wg in Manager
exists because a goroutine replaced by a reload could otherwise outlive Stop
and race t.TempDir() cleanup.
When adding a config field
- Add it to
Repo(andGlobalif it should be inheritable) inconfig.go. - Resolve it in
resolveJob— with an explicit default, never a zero value that downstream code has to interpret. - Add it to
Job, keeping the type comparable (see invariant 1). - Thread it through
syncer.Sync/gitx. - Document it in the README table and, if it is interesting,
config.example.toml. - Add a case to
config_test.go.unknownKeysrejects unrecognised keys, so a field that is parsed but not registered will make previously valid configs fail.