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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +257 -0
- data/bin/repo-tender +11 -0
- data/lib/repo_tender/cli/config.rb +66 -0
- data/lib/repo_tender/cli/daemon.rb +347 -0
- data/lib/repo_tender/cli/options.rb +21 -0
- data/lib/repo_tender/cli/org.rb +186 -0
- data/lib/repo_tender/cli/repo.rb +170 -0
- data/lib/repo_tender/cli/status.rb +76 -0
- data/lib/repo_tender/cli/sync.rb +149 -0
- data/lib/repo_tender/cli.rb +136 -0
- data/lib/repo_tender/config/contract.rb +53 -0
- data/lib/repo_tender/config/duration.rb +79 -0
- data/lib/repo_tender/config/model.rb +48 -0
- data/lib/repo_tender/config/store.rb +134 -0
- data/lib/repo_tender/forge/client.rb +31 -0
- data/lib/repo_tender/forge/github.rb +96 -0
- data/lib/repo_tender/launchd/agent.rb +195 -0
- data/lib/repo_tender/launchd/plist.rb +129 -0
- data/lib/repo_tender/log_rotator.rb +46 -0
- data/lib/repo_tender/paths.rb +72 -0
- data/lib/repo_tender/scm/client.rb +87 -0
- data/lib/repo_tender/scm/git.rb +232 -0
- data/lib/repo_tender/scm/status.rb +24 -0
- data/lib/repo_tender/shell.rb +90 -0
- data/lib/repo_tender/state/lock.rb +59 -0
- data/lib/repo_tender/state/store.rb +140 -0
- data/lib/repo_tender/sync/engine.rb +464 -0
- data/lib/repo_tender/sync/repo_plan.rb +215 -0
- data/lib/repo_tender/ui/interactive_reporter.rb +280 -0
- data/lib/repo_tender/ui/json_reporter.rb +39 -0
- data/lib/repo_tender/ui/mode.rb +68 -0
- data/lib/repo_tender/ui/plain_reporter.rb +53 -0
- data/lib/repo_tender/ui/reporter.rb +48 -0
- data/lib/repo_tender/version.rb +5 -0
- data/lib/repo_tender.rb +37 -0
- data/repo-tender.gemspec +47 -0
- 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
|
+
[](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
|