repo-tender 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +257 -0
  4. data/bin/repo-tender +11 -0
  5. data/lib/repo_tender/cli/config.rb +66 -0
  6. data/lib/repo_tender/cli/daemon.rb +347 -0
  7. data/lib/repo_tender/cli/options.rb +21 -0
  8. data/lib/repo_tender/cli/org.rb +186 -0
  9. data/lib/repo_tender/cli/repo.rb +170 -0
  10. data/lib/repo_tender/cli/status.rb +76 -0
  11. data/lib/repo_tender/cli/sync.rb +149 -0
  12. data/lib/repo_tender/cli.rb +136 -0
  13. data/lib/repo_tender/config/contract.rb +53 -0
  14. data/lib/repo_tender/config/duration.rb +79 -0
  15. data/lib/repo_tender/config/model.rb +48 -0
  16. data/lib/repo_tender/config/store.rb +134 -0
  17. data/lib/repo_tender/forge/client.rb +31 -0
  18. data/lib/repo_tender/forge/github.rb +96 -0
  19. data/lib/repo_tender/launchd/agent.rb +195 -0
  20. data/lib/repo_tender/launchd/plist.rb +129 -0
  21. data/lib/repo_tender/log_rotator.rb +46 -0
  22. data/lib/repo_tender/paths.rb +72 -0
  23. data/lib/repo_tender/scm/client.rb +87 -0
  24. data/lib/repo_tender/scm/git.rb +232 -0
  25. data/lib/repo_tender/scm/status.rb +24 -0
  26. data/lib/repo_tender/shell.rb +90 -0
  27. data/lib/repo_tender/state/lock.rb +59 -0
  28. data/lib/repo_tender/state/store.rb +140 -0
  29. data/lib/repo_tender/sync/engine.rb +464 -0
  30. data/lib/repo_tender/sync/repo_plan.rb +215 -0
  31. data/lib/repo_tender/ui/interactive_reporter.rb +280 -0
  32. data/lib/repo_tender/ui/json_reporter.rb +39 -0
  33. data/lib/repo_tender/ui/mode.rb +68 -0
  34. data/lib/repo_tender/ui/plain_reporter.rb +53 -0
  35. data/lib/repo_tender/ui/reporter.rb +48 -0
  36. data/lib/repo_tender/version.rb +5 -0
  37. data/lib/repo_tender.rb +37 -0
  38. data/repo-tender.gemspec +47 -0
  39. metadata +226 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c6a0eecbd892c6f3ca3e1f706ac8e36c9d3b421e4354416a3aaa293224ce7926
4
+ data.tar.gz: 7d278c8dd24320d2c582b8e8f0fa88e36f0b7e65f777983d3c68b2f3c15e2e7b
5
+ SHA512:
6
+ metadata.gz: 0d809b8db2a89cd6b0a48bb3fb2735a50b6515a27762b37f030d3a122dc72b26e43e4567c31b08b620da24a630bc7bc8796811268d3667d3e6a1af75e12f34b2
7
+ data.tar.gz: 176de9eb5000b95274e65111f77cd86fdad73d91616bab75d49b45d7054cdc7eaf5149cb7092b1e66f920692527305bf73f883ab76ff093b3d87d38748bdaa57
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Eric Jacobs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # repo-tender ๐ŸŒฒ
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/repo-tender.svg)](https://badge.fury.io/rb/repo-tender)
4
+
5
+ > **Keep your local git clones forever fresh!** ใƒฝ(โ€ขโ€ฟโ€ข)ใƒŽโœจ
6
+
7
+ `repo-tender` keeps your local git clones **evergreen** โ€” clean, on their
8
+ default branch, and recently fetched โ€” so anything that reads them gets a
9
+ current, trustworthy copy from your local disk instead of the network! ๐Ÿชžโšก
10
+
11
+ ## What does "evergreen" mean? ๐ŸŒฟ
12
+
13
+ A clone is **evergreen** when all three of these hold at once:
14
+
15
+ - ๐Ÿงผ **Clean** โ€” no modified, staged, untracked, or deleted files
16
+ - ๐ŸŒณ **On the default branch** โ€” whatever the remote calls it (`main`, `trunk`,
17
+ `master`โ€ฆ); repo-tender resolves it from the remote and never assumes!
18
+ - ๐Ÿ•ฐ๏ธ **Fresh** โ€” fast-forwarded to the remote within your `refresh_interval`
19
+ (default 6h)
20
+
21
+ repo-tender's whole job is to keep a tidy local mirror current, so another tool
22
+ can clone any of them from `~/src/evergreen/...` **instantly**. ๐Ÿš€
23
+
24
+ **Perfect for:**
25
+ - ๐Ÿชž A local mirror of the repos you clone from constantly
26
+ - ๐ŸŽ๏ธ A downstream "workspace" tool that clones from local disk, not the network
27
+ - ๐Ÿ™ Keeping a whole GitHub org checked out and current
28
+ - ๐ŸŒ™ Hands-off, set-and-forget background maintenance
29
+
30
+ **Why repo-tender rocks:**
31
+ - ๐Ÿ”’ **Never destroys your work** โ€” dirty or diverged repos are *reported*,
32
+ never touched. No `reset --hard`, ever.
33
+ - โšก Concurrent sync sweep powered by [socketry/async] โ€” fibers all the way
34
+ down, one process, no thread soup
35
+ - ๐Ÿค– A periodic [launchd] job syncs on a schedule while you sleep
36
+ - ๐Ÿ™ Track individual repos **or** whole GitHub orgs (expanded via `gh`)
37
+ - ๐ŸŽ›๏ธ Interactive, plain, or JSON output โ€” pretty for you, parseable for scripts
38
+ - ๐Ÿ’Ž Built on [dry-rb] โ€” validated YAML config, `Result`-typed boundaries
39
+
40
+ [socketry/async]: https://github.com/socketry/async
41
+ [launchd]: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html
42
+ [dry-rb]: https://dry-rb.org/
43
+
44
+ ## Requirements ๐ŸŒ
45
+
46
+ repo-tender is **macOS-only** (it schedules via launchd) and **GitHub-only**
47
+ (it lists orgs via `gh`) โ€” both sit behind decoupled interfaces, but those are
48
+ today's implementations.
49
+
50
+ | Tool | Version | Why |
51
+ |------|---------|-----|
52
+ | ๐ŸŽ macOS | โ€” | launchd scheduling, `~/Library/LaunchAgents` |
53
+ | [mise] | 2026.6+ | pins and provides Ruby |
54
+ | ๐Ÿ’Ž Ruby | 4.0.5 | runtime (pinned in `mise.toml`) |
55
+ | git | 2.54+ | the only SCM |
56
+ | [gh] | 2.93+ | GitHub org listing (must be authenticated) |
57
+
58
+ [mise]: https://mise.jdx.dev/
59
+ [gh]: https://cli.github.com/
60
+
61
+ ## Installation ๐Ÿ“ฆ
62
+
63
+ We're on RubyGems! ๐ŸŽ‰
64
+
65
+ ```bash
66
+ gem install repo-tender
67
+ repo-tender --help
68
+ ```
69
+
70
+ Prefer to hack on it? Install from source instead: ๐Ÿ› ๏ธ
71
+
72
+ ```bash
73
+ git clone git@github.com:jetpks/repo-tender.git
74
+ cd repo-tender
75
+ mise install # installs Ruby 4.0.5 per mise.toml
76
+ bundle install
77
+ bin/repo-tender --help
78
+ ```
79
+
80
+ Make sure `gh` is logged in (otherwise org listing drops to an anonymous
81
+ 60 req/hour limit):
82
+
83
+ ```bash
84
+ gh auth status
85
+ ```
86
+
87
+ And you're off! ๐ŸŽ€
88
+
89
+ ## Features โœจ
90
+
91
+ - **The evergreen invariant** โ€” clean ยท on default branch ยท fresh, checked per repo
92
+ - **Safe by default** โ€” dirty/diverged repos are reported and left byte-for-byte alone
93
+ - **Whole-org tracking** โ€” add `socketry` and get every repo it owns
94
+ - **Bounded concurrency** โ€” a fast async fan-out that won't melt your machine
95
+ - **launchd scheduling** โ€” `install`, `start`, `stop`, `restart`, `status`
96
+ - **Default-branch aware** โ€” resolves `trunk`/`master`/`main` from the remote
97
+ - **Three output modes** โ€” interactive TUI, plain text, or line-delimited JSON
98
+
99
+ ## Quick Start ๐ŸŽ€
100
+
101
+ ### Track a repo ๐Ÿ™
102
+
103
+ Repos are named `host/owner/name`:
104
+
105
+ ```bash
106
+ repo-tender repo add github.com/ruby/ruby
107
+ # => added: github.com/ruby/ruby
108
+
109
+ repo-tender repo list
110
+ # => github.com/ruby/ruby
111
+ ```
112
+
113
+ ### Track a whole org ๐ŸŒ
114
+
115
+ Orgs expand to their member repos at sync time (archived repos and forks are
116
+ excluded by default):
117
+
118
+ ```bash
119
+ repo-tender org add socketry # host defaults to github.com
120
+ repo-tender org add github.com/socketry # equivalent, explicit host
121
+ ```
122
+
123
+ ### Sync everything now โšก
124
+
125
+ ```bash
126
+ repo-tender sync
127
+ ```
128
+
129
+ Clones what's missing, fast-forwards what's clean-and-behind, and reports
130
+ (never touches!) anything dirty or diverged. Scope it to one repo with
131
+ `--repo github.com/ruby/ruby`. ๐ŸŽฏ
132
+
133
+ ### Check your repos' health ๐Ÿฉบ
134
+
135
+ ```bash
136
+ repo-tender status
137
+ ```
138
+
139
+ ```
140
+ REPO STATUS DEFAULT_BRANCH LAST_SYNCED_AT LAST_FETCH_AT
141
+ github.com/dry-rb/dry-monads clean main 2026-06-14T20:01:34Z 2026-06-14T20:01:33Z
142
+ github.com/ruby/ruby dirty trunk 2026-06-14T20:01:36Z 2026-06-14T20:01:35Z
143
+ ```
144
+
145
+ `clean` is the happy path. Anything else is repo-tender telling you a repo needs
146
+ *your* attention โ€” it won't touch it for you. ๐Ÿ”’
147
+
148
+ ### Schedule it & forget it ๐Ÿค–
149
+
150
+ Install a per-user launchd agent that runs `sync` every `refresh_interval`:
151
+
152
+ ```bash
153
+ repo-tender daemon install
154
+ repo-tender daemon status
155
+ ```
156
+
157
+ macOS now syncs your repos in the background. Tear it down anytime:
158
+
159
+ ```bash
160
+ repo-tender daemon stop
161
+ repo-tender daemon uninstall
162
+ ```
163
+
164
+ ### Tune it ๐ŸŽ›๏ธ
165
+
166
+ Find your config, then edit it (`repo-tender config path`):
167
+
168
+ ```yaml
169
+ base_dir: ~/src/evergreen # where clones live (pick your own!)
170
+ refresh_interval: 90m # "6h", "90m", "45s", "30d", or integer seconds
171
+ concurrency: 8 # max parallel git/gh operations per run
172
+ ```
173
+
174
+ ```bash
175
+ repo-tender config show # see the effective, validated config
176
+ ```
177
+
178
+ ### Output for robots ๐Ÿค“
179
+
180
+ ```bash
181
+ repo-tender sync --json # one JSON object per event line (12-factor!)
182
+ repo-tender sync --plain # one plain line per event, no color
183
+ repo-tender status --json
184
+ ```
185
+
186
+ `--no-color` and `--quiet`/`-q` work everywhere; color auto-disables off a TTY.
187
+
188
+ ## Command Overview ๐Ÿ”
189
+
190
+ **Track repos & orgs:**
191
+ - `repo add|remove|list REF` โ€” manage individual repos (`host/owner/name`)
192
+ - `org add|remove|list NAME` โ€” manage whole orgs (`name` or `host/name`)
193
+
194
+ **Run & inspect:**
195
+ - `sync [--repo REF]` โ€” run one sync pass (optionally scoped to one repo)
196
+ - `status` โ€” print the per-repo evergreen status table (reads state, no network)
197
+ - `config path|show` โ€” show the config path, or the effective config
198
+
199
+ **Schedule (launchd):**
200
+ - `daemon install|uninstall` โ€” write/remove the launchd agent
201
+ - `daemon start|stop|restart` โ€” enable / disable / run-now
202
+ - `daemon status` โ€” loaded? running? last exit?
203
+
204
+ **Global flags:** `--plain` ยท `--json` ยท `--no-color` ยท `--quiet`/`-q` ยท
205
+ `--help`/`-h` ยท `--version`
206
+
207
+ ## How it works ๐Ÿ› ๏ธ
208
+
209
+ The bits worth knowing the *why* of:
210
+
211
+ - ๐Ÿ”’ **The cardinal rule: never lose your work.** repo-tender only ever
212
+ fast-forwards a *clean* repo that's strictly behind. A dirty tree, local
213
+ commits the remote lacks, a detached HEAD, a non-default branch โ€” all
214
+ *reported* and left untouched. There is no destructive path.
215
+ - ๐Ÿค– **A periodic launchd job, not a resident daemon.** No socket, no IPC, no
216
+ in-process scheduler. launchd wakes a short-lived `sync` every
217
+ `refresh_interval`; it fans out, writes state, and exits. (`StartInterval` +
218
+ `RunAtLoad`, no `KeepAlive`.)
219
+ - ๐Ÿ“ก **Local-first, network-last.** Each sync checks on-disk facts first โ€” path
220
+ present? on default branch? clean? `.git/FETCH_HEAD` younger than the
221
+ interval? โ€” and only *then* touches the network. Re-runs are cheap and
222
+ idempotent. โœจ
223
+ - ๐Ÿ—‚๏ธ **Config vs. state.** `config.yaml` is *your* durable intent (hand-edited or
224
+ via the CLI); `state.yaml` is machine-managed (statuses, fetch times,
225
+ org expansions). Splitting them keeps machine rewrites away from the file you
226
+ actually wrote.
227
+
228
+ ## Documentation ๐Ÿ“–
229
+
230
+ - **[Full Reference](docs/reference.md)** ๐Ÿ“˜ โ€” every command, flag, config key,
231
+ status value, file location, and exit code
232
+ - **[Design (PRD)](docs/prd/repo-tender.md)** ๐Ÿ—๏ธ โ€” the full design & decisions
233
+ - **[Builder context](AGENTS.md)** ๐Ÿค โ€” toolchain & conventions
234
+
235
+ ## Development ๐Ÿงช
236
+
237
+ Want to hack on it? Yay! ๐ŸŽ‰
238
+
239
+ ```bash
240
+ bundle install
241
+ bundle exec rake test # full minitest suite
242
+ bundle exec standardrb # lint / format check
243
+ bundle exec standardrb --fix # autofix
244
+ ```
245
+
246
+ ## Contributing ๐Ÿ’
247
+
248
+ Bug reports and pull requests are welcome at
249
+ [github.com/jetpks/repo-tender](https://github.com/jetpks/repo-tender)! ๐ŸŒฒ
250
+
251
+ ## License ๐Ÿ“„
252
+
253
+ Available as open source under the terms of the [MIT License](LICENSE.txt).
254
+
255
+ ---
256
+
257
+ Made with ๐Ÿ’– and a deep distrust of `reset --hard`, by Eric ๐ŸŒฒ
data/bin/repo-tender ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby -W:no-experimental
2
+ # frozen_string_literal: true
3
+
4
+ # repo-tender CLI entrypoint. Loads the gem, hands argv to the
5
+ # Dry::CLI registry, and translates the recorded Outcome to a
6
+ # process exit code. See lib/repo_tender/cli.rb for the seam.
7
+
8
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
9
+ require "repo_tender"
10
+
11
+ RepoTender::CLI.run(ARGV, $stdout, $stderr)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "yaml"
5
+ require "repo_tender/cli"
6
+ require "repo_tender/ui/mode"
7
+ require "repo_tender/cli/options"
8
+
9
+ module RepoTender
10
+ module CLI
11
+ # `config` command group: path / show.
12
+ module ConfigCmd
13
+ class Path < Dry::CLI::Command
14
+ include GlobalOptions
15
+
16
+ desc "Print the resolved config file path (honors $XDG_CONFIG_HOME)"
17
+
18
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
19
+ mode = UI::Mode.resolve(
20
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
21
+ env: CLI.env,
22
+ out: out
23
+ )
24
+ pastel = Pastel.new(enabled: mode.color)
25
+
26
+ paths = CLI.make_paths
27
+ out.puts pastel.cyan(paths.config_file)
28
+ CLI.record_outcome(Outcome.new(exit_code: 0))
29
+ end
30
+ end
31
+
32
+ class Show < Dry::CLI::Command
33
+ include GlobalOptions
34
+
35
+ desc "Print the effective (validated, defaults-applied) config as YAML"
36
+
37
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
38
+ mode = UI::Mode.resolve(
39
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
40
+ env: CLI.env,
41
+ out: out
42
+ )
43
+ pastel = Pastel.new(enabled: mode.color)
44
+
45
+ paths = CLI.make_paths
46
+ config = Config::Store.load(paths.config_file).success
47
+ # Emit via the store's own emit() so the format matches
48
+ # what `config.yaml` looks like on disk (stable key order
49
+ # per Slice 1's emit implementation). This makes
50
+ # `config show` a faithful round-trip preview.
51
+ out.puts pastel.cyan(Config::Store.emit(config.to_h).chomp)
52
+ CLI.record_outcome(Outcome.new(exit_code: 0))
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ # `config` is the name of both our config-store module (Config) and
60
+ # the CLI command group. We register under a different module name
61
+ # (ConfigCmd) to avoid the constant clash, then alias the
62
+ # registration key as "config".
63
+ RepoTender::CLI::Registry.register "config" do |prefix|
64
+ prefix.register "path", RepoTender::CLI::ConfigCmd::Path
65
+ prefix.register "show", RepoTender::CLI::ConfigCmd::Show
66
+ end