repo-tender ๐ฒ
Keep your local git clones forever fresh! ใฝ(โขโฟโข)ใโจ
repo-tender keeps your local git clones evergreen โ clean, on their
default branch, and recently fetched โ so anything that reads them gets a
current, trustworthy copy from your local disk instead of the network! ๐ชโก
What does "evergreen" mean? ๐ฟ
A clone is evergreen when all three of these hold at once:
- ๐งผ Clean โ no modified, staged, untracked, or deleted files
- ๐ณ On the default branch โ whatever the remote calls it (
main,trunk,masterโฆ); repo-tender resolves it from the remote and never assumes! - ๐ฐ๏ธ Fresh โ fast-forwarded to the remote within your
refresh_interval(default 6h)
repo-tender's whole job is to keep a tidy local mirror current, so another tool
can clone any of them from ~/src/evergreen/... instantly. ๐
Perfect for:
- ๐ช A local mirror of the repos you clone from constantly
- ๐๏ธ A downstream "workspace" tool that clones from local disk, not the network
- ๐ Keeping a whole GitHub org checked out and current
- ๐ Hands-off, set-and-forget background maintenance
Why repo-tender rocks:
- ๐ Never destroys your work โ dirty or diverged repos are reported,
never touched. No
reset --hard, ever. - โก Concurrent sync sweep powered by socketry/async โ fibers all the way down, one process, no thread soup
- ๐ค A periodic launchd job syncs on a schedule while you sleep
- ๐ Track individual repos or whole GitHub orgs (expanded via
gh) - ๐๏ธ Interactive, plain, or JSON output โ pretty for you, parseable for scripts
- ๐ Built on dry-rb โ validated YAML config,
Result-typed boundaries
Requirements ๐
repo-tender is macOS-only (it schedules via launchd) and GitHub-only
(it lists orgs via gh) โ both sit behind decoupled interfaces, but those are
today's implementations.
| Tool | Version | Why |
|---|---|---|
| ๐ macOS | โ | launchd scheduling, ~/Library/LaunchAgents |
| mise | 2026.6+ | pins and provides Ruby |
| ๐ Ruby | 4.0.5 | runtime (pinned in mise.toml) |
| git | 2.54+ | the only SCM |
| gh | 2.93+ | GitHub org listing (must be authenticated) |
Installation ๐ฆ
We're on RubyGems! ๐
gem install repo-tender
repo-tender --help
Prefer to hack on it? Install from source instead: ๐ ๏ธ
git clone git@github.com:jetpks/repo-tender.git
cd repo-tender
mise install # installs Ruby 4.0.5 per mise.toml
bundle install
bin/repo-tender --help
Make sure gh is logged in (otherwise org listing drops to an anonymous
60 req/hour limit):
gh auth status
And you're off! ๐
Features โจ
- The evergreen invariant โ clean ยท on default branch ยท fresh, checked per repo
- Safe by default โ dirty/diverged repos are reported and left byte-for-byte alone
- Whole-org tracking โ add
socketryand get every repo it owns - Bounded concurrency โ a fast async fan-out that won't melt your machine
- launchd scheduling โ
install,start,stop,restart,status - Default-branch aware โ resolves
trunk/master/mainfrom the remote - Three output modes โ interactive TUI, plain text, or line-delimited JSON
Quick Start ๐
Track a repo ๐
Repos are named host/owner/name:
repo-tender repo add github.com/ruby/ruby
# => added: github.com/ruby/ruby
repo-tender repo list
# => github.com/ruby/ruby
Track a whole org ๐
Orgs expand to their member repos at sync time (archived repos and forks are excluded by default):
repo-tender org add socketry # host defaults to github.com
repo-tender org add github.com/socketry # equivalent, explicit host
Sync everything now โก
repo-tender sync
Clones what's missing, fast-forwards what's clean-and-behind, and reports
(never touches!) anything dirty or diverged. Scope it to one repo with
--repo github.com/ruby/ruby. ๐ฏ
Check your repos' health ๐ฉบ
repo-tender status
REPO STATUS DEFAULT_BRANCH LAST_SYNCED_AT LAST_FETCH_AT
github.com/dry-rb/dry-monads clean main 2026-06-14T20:01:34Z 2026-06-14T20:01:33Z
github.com/ruby/ruby dirty trunk 2026-06-14T20:01:36Z 2026-06-14T20:01:35Z
clean is the happy path. Anything else is repo-tender telling you a repo needs
your attention โ it won't touch it for you. ๐
Schedule it & forget it ๐ค
Install a per-user launchd agent that runs sync every refresh_interval:
repo-tender daemon install
repo-tender daemon status
macOS now syncs your repos in the background. Tear it down anytime:
repo-tender daemon stop
repo-tender daemon uninstall
Tune it ๐๏ธ
Find your config, then edit it (repo-tender config path):
base_dir: ~/src/evergreen # where clones live (pick your own!)
refresh_interval: 90m # "6h", "90m", "45s", "30d", or integer seconds
concurrency: 8 # max parallel git/gh operations per run
repo-tender config show # see the effective, validated config
Output for robots ๐ค
repo-tender sync --json # one JSON object per event line (12-factor!)
repo-tender sync --plain # one plain line per event, no color
repo-tender status --json
--no-color and --quiet/-q work everywhere; color auto-disables off a TTY.
Command Overview ๐
Track repos & orgs:
repo add|remove|list REFโ manage individual repos (host/owner/name)org add|remove|list NAMEโ manage whole orgs (nameorhost/name)
Run & inspect:
sync [--repo REF]โ run one sync pass (optionally scoped to one repo)statusโ print the per-repo evergreen status table (reads state, no network)config path|showโ show the config path, or the effective config
Schedule (launchd):
daemon install|uninstallโ write/remove the launchd agentdaemon start|stop|restartโ enable / disable / run-nowdaemon statusโ loaded? running? last exit?
Global flags: --plain ยท --json ยท --no-color ยท --quiet/-q ยท
--help/-h ยท --version
How it works ๐ ๏ธ
The bits worth knowing the why of:
- ๐ The cardinal rule: never lose your work. repo-tender only ever fast-forwards a clean repo that's strictly behind. A dirty tree, local commits the remote lacks, a detached HEAD, a non-default branch โ all reported and left untouched. There is no destructive path.
- ๐ค A periodic launchd job, not a resident daemon. No socket, no IPC, no
in-process scheduler. launchd wakes a short-lived
synceveryrefresh_interval; it fans out, writes state, and exits. (StartInterval+RunAtLoad, noKeepAlive.) - ๐ก Local-first, network-last. Each sync checks on-disk facts first โ path
present? on default branch? clean?
.git/FETCH_HEADyounger than the interval? โ and only then touches the network. Re-runs are cheap and idempotent. โจ - ๐๏ธ Config vs. state.
config.yamlis your durable intent (hand-edited or via the CLI);state.yamlis machine-managed (statuses, fetch times, org expansions). Splitting them keeps machine rewrites away from the file you actually wrote.
Documentation ๐
- Full Reference ๐ โ every command, flag, config key, status value, file location, and exit code
- Design (PRD) ๐๏ธ โ the full design & decisions
- Builder context ๐ค โ toolchain & conventions
Development ๐งช
Want to hack on it? Yay! ๐
bundle install
bundle exec rake test # full minitest suite
bundle exec standardrb # lint / format check
bundle exec standardrb --fix # autofix
Contributing ๐
Bug reports and pull requests are welcome at github.com/jetpks/repo-tender! ๐ฒ
License ๐
Available as open source under the terms of the MIT License.
Made with ๐ and a deep distrust of reset --hard, by Eric ๐ฒ