repo-tender 0.1.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6a0eecbd892c6f3ca3e1f706ac8e36c9d3b421e4354416a3aaa293224ce7926
4
- data.tar.gz: 7d278c8dd24320d2c582b8e8f0fa88e36f0b7e65f777983d3c68b2f3c15e2e7b
3
+ metadata.gz: 3cdef875cd3f2076b9c58047f9b59844846957ac24a5deee9c615e2cd30a8d7e
4
+ data.tar.gz: d810d8e25f1ce4262a63994ca7f3c0c752b876f549b33c870e9557736175ac70
5
5
  SHA512:
6
- metadata.gz: 0d809b8db2a89cd6b0a48bb3fb2735a50b6515a27762b37f030d3a122dc72b26e43e4567c31b08b620da24a630bc7bc8796811268d3667d3e6a1af75e12f34b2
7
- data.tar.gz: 176de9eb5000b95274e65111f77cd86fdad73d91616bab75d49b45d7054cdc7eaf5149cb7092b1e66f920692527305bf73f883ab76ff093b3d87d38748bdaa57
6
+ metadata.gz: 9fe1a7e54338a7806dd7b6c7686a8008a045b28c5ef9afb622548adc0e9e98872c6571752923aa47fb7ce94caf1a529f3f9b72557aefacece14ecd620a48ca54
7
+ data.tar.gz: e62fd711352b672fa0ab5fb7fc7f76184f21952ade51a88fb585ad4a1bb7253e93332baf037991b43454ea6f4b062a49a6450b6352d29e249f301b25ed6f398d
data/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ·
6
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
7
+
8
+ ## [1.0.0] - 2026-10-08
9
+
10
+ ### Restored
11
+
12
+ - **Standalone again.** The evergreen engine was absorbed into
13
+ [space-architect] v8.0.0 under that gem's namespace (`Space::Src`) and
14
+ binary name (`src`); this release restores the `repo-tender` identity —
15
+ same engine, home repo [jetpks/repo-tender] again.
16
+ Lineage: 0.x repo-tender → absorbed as space-src in space-architect 8.0.0 →
17
+ restored standalone 1.0.0.
18
+
19
+ [space-architect]: https://github.com/jetpks/space-architect
20
+ [jetpks/repo-tender]: https://github.com/jetpks/repo-tender
21
+
22
+ ### Changed
23
+
24
+ - **Binaries:** `repo-tender` is the primary binary again. `src` ships as a
25
+ deprecation shim — it still works, but prints
26
+ `src is deprecated; use repo-tender instead` on stderr on every invocation,
27
+ then forwards argv/stdout/exit codes to the primary binary unchanged.
28
+ - **State/config locations:** `repo-tender` is the app name for
29
+ `$XDG_STATE_HOME` / `$XDG_CONFIG_HOME` dirs and the launchd label is
30
+ `io.github.jetpks.repo-tender.sync` again.
31
+ - **Identity migration inverted:** a one-shot data-preserving migration moves
32
+ `space-src`-named config/state dirs to their `repo-tender` locations and
33
+ flags the stale `io.github.jetpks.space-src.sync` launchd agent. Moves only,
34
+ never deletes; pre-existing `repo-tender` dirs are a clean no-op.
35
+ - **Module namespace:** `Space::Src` → `RepoTender` throughout.
36
+ - **Ruby floor:** `>= 4.0.5` (was `>= 3.3` in the 0.x gem).
37
+ - **Default clone home:** `~/src/evergreen` (was the absorbed identity's
38
+ `~/architect/src`).
39
+
40
+ ### Added
41
+
42
+ - Fish shell integration and completions, installed as
43
+ `~/.config/fish/functions/repo-tender.fish` /
44
+ `~/.config/fish/completions/repo-tender.fish` via
45
+ `repo-tender shell fish install`.
data/README.md CHANGED
@@ -74,7 +74,7 @@ git clone git@github.com:jetpks/repo-tender.git
74
74
  cd repo-tender
75
75
  mise install # installs Ruby 4.0.5 per mise.toml
76
76
  bundle install
77
- bin/repo-tender --help
77
+ exe/repo-tender --help
78
78
  ```
79
79
 
80
80
  Make sure `gh` is logged in (otherwise org listing drops to an anonymous
@@ -120,6 +120,17 @@ repo-tender org add socketry # host defaults to github.com
120
120
  repo-tender org add github.com/socketry # equivalent, explicit host
121
121
  ```
122
122
 
123
+ Got a few truly enormous repos in an org you never want cloned? Exclude them
124
+ with `--ignored-repos` (bare `name` or `owner/name`, comma-separated):
125
+
126
+ ```bash
127
+ repo-tender org add bigco --ignored-repos monorepo,huge
128
+ # => added: github.com/bigco (… ignored_repos=["monorepo", "huge"])
129
+ ```
130
+
131
+ The ignore list is authoritative at expansion time — an ignored repo never
132
+ enters a sync sweep. ✋
133
+
123
134
  ### Sync everything now ⚡
124
135
 
125
136
  ```bash
@@ -145,6 +156,23 @@ github.com/ruby/ruby dirty trunk 2026-06-14T20:01:36Z 20
145
156
  `clean` is the happy path. Anything else is repo-tender telling you a repo needs
146
157
  *your* attention — it won't touch it for you. 🔒
147
158
 
159
+ ### Grab an instant copy ⚡🐄
160
+
161
+ Need a working copy of an evergreen repo? `clone` makes a near-instant
162
+ copy-on-write clone (macOS APFS `cp -Rc`) from your local mirror — no network,
163
+ barely any disk:
164
+
165
+ ```bash
166
+ repo-tender clone ruby # resolves the single ruby/ruby mirror
167
+ repo-tender clone ruby async --into ~/wip # copy several into a parent dir
168
+ ```
169
+
170
+ Name a repo bare (`ruby`), `owner/name`, or `host/owner/name` — bare names that
171
+ match more than one mirror error out and list the candidates so you can qualify.
172
+ `--into` is the destination **parent** (default `.`); each repo lands at
173
+ `<into>/<name>`. And it **never clobbers** — an existing destination is left
174
+ byte-for-byte alone. 🔒
175
+
148
176
  ### Schedule it & forget it 🤖
149
177
 
150
178
  Install a per-user launchd agent that runs `sync` every `refresh_interval`:
@@ -189,11 +217,13 @@ repo-tender status --json
189
217
 
190
218
  **Track repos & orgs:**
191
219
  - `repo add|remove|list REF` — manage individual repos (`host/owner/name`)
192
- - `org add|remove|list NAME` — manage whole orgs (`name` or `host/name`)
220
+ - `org add|remove|list NAME` — manage whole orgs (`name` or `host/name`);
221
+ `org add` takes `--include-archived`, `--include-forks`, `--ignored-repos a,b`
193
222
 
194
223
  **Run & inspect:**
195
224
  - `sync [--repo REF]` — run one sync pass (optionally scoped to one repo)
196
225
  - `status` — print the per-repo evergreen status table (reads state, no network)
226
+ - `clone NAME... [--into DIR]` — instant COW copy of mirror(s) into a parent dir
197
227
  - `config path|show` — show the config path, or the effective config
198
228
 
199
229
  **Schedule (launchd):**
@@ -225,11 +255,29 @@ The bits worth knowing the *why* of:
225
255
  org expansions). Splitting them keeps machine rewrites away from the file you
226
256
  actually wrote.
227
257
 
258
+ ## The `src` shim & lineage 🧬
259
+
260
+ repo-tender's story has a detour in it: the engine was absorbed into
261
+ [space-architect] v8.0.0 under that gem's namespace and binary name (`src`,
262
+ state under `~/.local/state/space-src`), and has now been restored standalone
263
+ here as repo-tender 1.0.0 — same engine, own identity again.
264
+
265
+ [space-architect]: https://github.com/jetpks/space-architect
266
+
267
+ Two things happen for anyone arriving from the absorbed identity:
268
+
269
+ - **`src` still works** — it's shipped as a deprecation shim. Every invocation
270
+ prints `src is deprecated; use repo-tender instead` on stderr, then forwards
271
+ argv, stdout, and exit codes to the primary binary unchanged.
272
+ - **State migrates automatically** — on first run of any `repo-tender`
273
+ command, a one-shot data-preserving migration moves your `space-src` config
274
+ and state dirs to their `repo-tender` locations (a no-clobber move; existing
275
+ `repo-tender` dirs are never touched) and flags a stale launchd agent with
276
+ the `repo-tender daemon install` instruction.
277
+
228
278
  ## Documentation 📖
229
279
 
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
280
+ - **[Changelog](CHANGELOG.md)** 📜 — what's new (1.0.0: standalone again)
233
281
  - **[Builder context](AGENTS.md)** 🤝 — toolchain & conventions
234
282
 
235
283
  ## Development 🧪
data/exe/repo-tender ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ source_gemfile = File.expand_path("../Gemfile", __dir__)
5
+ if File.exist?(source_gemfile)
6
+ ENV["BUNDLE_GEMFILE"] ||= source_gemfile
7
+ require "bundler/setup"
8
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
9
+ end
10
+
11
+ require "repo_tender"
12
+
13
+ RepoTender::CLI.run(ARGV, $stdout, $stderr)
data/exe/src ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Deprecation shim for the `src` binary name (the engine lived briefly under
5
+ # the absorbed identity). Warns on EVERY invocation, then forwards
6
+ # argv/stdout/exit codes to the primary binary unchanged — including the
7
+ # usage output, which renders $PROGRAM_NAME's basename (so the shim presents
8
+ # the primary binary's name for byte-identical --help parity).
9
+
10
+ warn "src is deprecated; use repo-tender instead"
11
+
12
+ source_gemfile = File.expand_path("../Gemfile", __dir__)
13
+ if File.exist?(source_gemfile)
14
+ ENV["BUNDLE_GEMFILE"] ||= source_gemfile
15
+ require "bundler/setup"
16
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
17
+ end
18
+
19
+ $PROGRAM_NAME = File.join(File.expand_path(__dir__), "repo-tender")
20
+
21
+ require "repo_tender"
22
+
23
+ RepoTender::CLI.run(ARGV, $stdout, $stderr)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "dry/monads"
5
+ require "repo_tender/cli/options"
6
+ require "repo_tender/cloner"
7
+
8
+ module RepoTender
9
+ module CLI
10
+ # `clone` command: APFS COW copy of an evergreen repo into a working dir.
11
+ # Resolves each NAME against config.base_dir and copies via cp -Rc.
12
+ # Multiple names are processed independently; a per-name failure is
13
+ # reported on err and does not abort the others. Exit code is 1 if any
14
+ # name failed, else 0.
15
+ class Clone < Dry::CLI::Command
16
+ include GlobalOptions
17
+
18
+ desc "Clone evergreen repo(s) into a working directory (APFS COW copy)"
19
+ argument :names, type: :array, required: true,
20
+ desc: "Repo name(s): bare name, owner/name, or host/owner/name"
21
+ option :into, default: ".",
22
+ desc: "Destination parent directory (default: current working directory)"
23
+
24
+ def call(names:, into: ".", plain: nil, json: nil, no_color: nil, quiet: nil, **)
25
+ mode = UI::Mode.resolve(
26
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
27
+ env: CLI.env,
28
+ out: out
29
+ )
30
+ pastel = Pastel.new(enabled: mode.color)
31
+
32
+ paths = CLI.make_paths
33
+ config = Config::Store.load(paths.config_file).success
34
+ cloner = Cloner.new(base_dir: config.base_dir)
35
+
36
+ any_failure = false
37
+ names.each do |name|
38
+ result = cloner.call(name: name, into: into)
39
+ if result.success?
40
+ dest = result.success
41
+ out.puts pastel.green("cloned: #{name} → #{dest}")
42
+ else
43
+ err.puts result.failure
44
+ any_failure = true
45
+ end
46
+ end
47
+
48
+ CLI.record_outcome(Outcome.new(exit_code: any_failure ? 1 : 0))
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ RepoTender::CLI::Registry.register "clone", RepoTender::CLI::Clone
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "yaml"
5
- require "repo_tender/cli"
6
5
  require "repo_tender/ui/mode"
7
6
  require "repo_tender/cli/options"
8
7
 
@@ -3,11 +3,11 @@
3
3
  require "pastel"
4
4
  require "fileutils"
5
5
  require "dry/monads"
6
- require "repo_tender/cli"
7
6
  require "repo_tender/ui/mode"
8
7
  require "repo_tender/cli/options"
9
8
  require "repo_tender/launchd/agent"
10
9
  require "repo_tender/launchd/plist"
10
+ require "repo_tender/migration"
11
11
 
12
12
  module RepoTender
13
13
  module CLI
@@ -57,7 +57,7 @@ module RepoTender
57
57
  # toolchain-resolved ruby; pinned via mise.toml).
58
58
  # * `bin_path` — `RbConfig.ruby` + the script path
59
59
  # (we use `__dir__` of this file's caller; for the
60
- # gem install, this is `<gem>/bin/repo-tender`).
60
+ # gem install, this is `<gem>/exe/repo-tender`).
61
61
  #
62
62
  # In tests, we inject these via the `Resolve` object
63
63
  # (see below) — never call out to the shell.
@@ -113,6 +113,15 @@ module RepoTender
113
113
  label = Launchd::Agent::DEFAULT_LABEL
114
114
  pp = plist_path(paths, label)
115
115
 
116
+ # Relabel: if old-identity plist exists, bootout (benign-failure-tolerant)
117
+ # and remove it before bootstrapping the new-label plist.
118
+ old_pp = plist_path(paths, Migration::OLD_LABEL)
119
+ if File.exist?(old_pp)
120
+ old_agent = make_agent(label: Migration::OLD_LABEL)
121
+ old_agent.uninstall
122
+ File.delete(old_pp) if File.exist?(old_pp)
123
+ end
124
+
116
125
  resolve = Resolve.detect(repo_root: Dir.pwd)
117
126
  xml = build_plist(resolve: resolve, config: config, paths: paths, label: label)
118
127
  FileUtils.mkdir_p(File.dirname(pp))
@@ -264,6 +273,12 @@ module RepoTender
264
273
  )
265
274
  pastel = Pastel.new(enabled: mode.color)
266
275
 
276
+ paths = CLI.make_paths
277
+ old_pp = plist_path(paths, Migration::OLD_LABEL)
278
+ if File.exist?(old_pp)
279
+ err.puts pastel.yellow("warning: stale agent #{Migration::OLD_LABEL} detected; run `repo-tender daemon install` to upgrade")
280
+ end
281
+
267
282
  label = Launchd::Agent::DEFAULT_LABEL
268
283
  agent = make_agent
269
284
  result = agent.status
@@ -283,11 +298,11 @@ module RepoTender
283
298
  end
284
299
  end
285
300
 
286
- # Detect the runtime paths the plist needs. The repo-tender
287
- # install path matters because the plist stores an absolute
288
- # `bin_path` — that is the script launchd invokes. We resolve
289
- # `bin_path` from the on-disk gem layout if we can, else fall
290
- # back to the directory the daemon command was run from.
301
+ # Detect the runtime paths the plist needs. The repo-tender install path
302
+ # matters because the plist stores an absolute `bin_path` — that
303
+ # is the script launchd invokes. We resolve `bin_path` from the
304
+ # on-disk gem layout if we can, else fall back to the directory
305
+ # the daemon command was run from.
291
306
  class RepoTender::CLI::Daemon::Helpers::Resolve
292
307
  # @param repo_root [String] absolute path of the working directory (where mise.toml is expected)
293
308
  # @return [Resolve]
@@ -323,10 +338,10 @@ class RepoTender::CLI::Daemon::Helpers::Resolve
323
338
  def self.detect_bin_path(repo_root)
324
339
  path = ENV["REPO_TENDER_BIN_PATH"]
325
340
  return path if path && !path.empty?
326
- # Prefer the on-disk dev bin at `<repo_root>/bin/repo-tender`
341
+ # Prefer the on-disk dev bin at `<repo_root>/exe/repo-tender`
327
342
  # — it's what the human runs during testing, and the gem is
328
343
  # typically not `gem install`ed in a source checkout.
329
- dev = File.join(repo_root, "bin", "repo-tender")
344
+ dev = File.join(repo_root, "exe", "repo-tender")
330
345
  return dev if File.exist?(dev)
331
346
  # Next, an installed binary on PATH.
332
347
  require "open3"
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "repo_tender/cli"
6
5
  require "repo_tender/ui/mode"
7
6
  require "repo_tender/cli/options"
8
7
 
@@ -20,7 +19,7 @@ module RepoTender
20
19
  # Parse an org ref string. Accepts "github.com/socketry" or
21
20
  # bare "socketry" (defaults host to github.com). Anything
22
21
  # with 3+ parts is rejected (org has only host + name).
23
- def parse_ref(name, include_archived: false, include_forks: false)
22
+ def parse_ref(name, include_archived: false, include_forks: false, ignored_repos: [])
24
23
  parts = name.to_s.split("/")
25
24
  case parts.length
26
25
  when 1
@@ -30,7 +29,8 @@ module RepoTender
30
29
  host: Config::DEFAULT_HOST,
31
30
  name: org,
32
31
  include_archived: include_archived,
33
- include_forks: include_forks
32
+ include_forks: include_forks,
33
+ ignored_repos: ignored_repos
34
34
  ))
35
35
  when 2
36
36
  host, n = parts
@@ -40,7 +40,8 @@ module RepoTender
40
40
  host: host,
41
41
  name: n,
42
42
  include_archived: include_archived,
43
- include_forks: include_forks
43
+ include_forks: include_forks,
44
+ ignored_repos: ignored_repos
44
45
  ))
45
46
  else
46
47
  Dry::Monads::Failure("invalid org reference: #{name.inspect} (expected \"<name>\" or \"<host>/<name>\")")
@@ -51,6 +52,11 @@ module RepoTender
51
52
  def format_ref(o) = "#{o.host}/#{o.name}"
52
53
  def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
53
54
 
55
+ def format_ignored(o)
56
+ return "" if o.ignored_repos.empty?
57
+ " ignored_repos=#{o.ignored_repos.inspect}"
58
+ end
59
+
54
60
  def fail_with(cmd, msg)
55
61
  cmd.send(:err).puts msg
56
62
  RepoTender::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
@@ -68,8 +74,10 @@ module RepoTender
68
74
  desc: "Include archived repos when expanding the org"
69
75
  option :include_forks, type: :boolean, default: false,
70
76
  desc: "Include forks when expanding the org"
77
+ option :ignored_repos, type: :array, default: [],
78
+ desc: "Repos to exclude from expansion (bare name or owner/name)"
71
79
 
72
- def call(name:, include_archived: false, include_forks: false, plain: nil, json: nil, no_color: nil, quiet: nil, **)
80
+ def call(name:, include_archived: false, include_forks: false, ignored_repos: [], plain: nil, json: nil, no_color: nil, quiet: nil, **)
73
81
  mode = UI::Mode.resolve(
74
82
  flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
75
83
  env: CLI.env,
@@ -79,7 +87,8 @@ module RepoTender
79
87
 
80
88
  parsed = parse_ref(name,
81
89
  include_archived: include_archived,
82
- include_forks: include_forks)
90
+ include_forks: include_forks,
91
+ ignored_repos: ignored_repos)
83
92
  return fail_with(self, parsed.failure) if parsed.failure?
84
93
 
85
94
  new_ref = parsed.success
@@ -88,7 +97,8 @@ module RepoTender
88
97
 
89
98
  if config.orgs.any? { |o| same_org?(o, new_ref) }
90
99
  out.puts pastel.yellow("already tracked: #{format_ref(new_ref)}" \
91
- " (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})")
100
+ " (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})" \
101
+ "#{format_ignored(new_ref)}")
92
102
  return CLI.record_outcome(Outcome.new(exit_code: 0))
93
103
  end
94
104
 
@@ -100,7 +110,8 @@ module RepoTender
100
110
  end
101
111
 
102
112
  out.puts pastel.green("added: #{format_ref(new_ref)}" \
103
- " (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})")
113
+ " (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})" \
114
+ "#{format_ignored(new_ref)}")
104
115
  CLI.record_outcome(Outcome.new(exit_code: 0))
105
116
  end
106
117
  end
@@ -150,6 +161,7 @@ module RepoTender
150
161
  end
151
162
 
152
163
  class List < Dry::CLI::Command
164
+ include Helpers
153
165
  include GlobalOptions
154
166
 
155
167
  desc "List tracked orgs"
@@ -169,7 +181,8 @@ module RepoTender
169
181
  else
170
182
  config.orgs.each do |o|
171
183
  out.puts pastel.cyan("#{o.host}/#{o.name}" \
172
- " (include_archived=#{o.include_archived}, include_forks=#{o.include_forks})")
184
+ " (include_archived=#{o.include_archived}, include_forks=#{o.include_forks})" \
185
+ "#{format_ignored(o)}")
173
186
  end
174
187
  end
175
188
  CLI.record_outcome(Outcome.new(exit_code: 0))
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "pastel"
4
4
  require "dry/monads"
5
- require "repo_tender/cli"
6
5
  require "repo_tender/ui/mode"
7
6
  require "repo_tender/cli/options"
8
7
 
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "repo_tender/shell_integration"
4
+
5
+ module RepoTender
6
+ module CLI
7
+ module Shell
8
+ class Init < Dry::CLI::Command
9
+ desc "Print shell integration script"
10
+ argument :shell_name, required: true, desc: "Shell name (e.g. fish)"
11
+
12
+ def call(shell_name:, **)
13
+ out.puts ShellIntegration.for(shell_name)
14
+ CLI.record_outcome(Outcome.new(exit_code: 0))
15
+ rescue => e
16
+ err.puts "repo-tender shell init: #{e.message}"
17
+ CLI.record_outcome(Outcome.new(exit_code: 1))
18
+ end
19
+ end
20
+
21
+ class Fish < Dry::CLI::Command
22
+ desc "Manage fish shell integration: install, uninstall, path"
23
+ argument :subcommand, required: false, desc: "install, uninstall, or path (default: install)"
24
+ option :force, type: :boolean, default: false, desc: "Overwrite or remove existing shell files"
25
+
26
+ def call(subcommand: "install", force: false, **)
27
+ case subcommand
28
+ when "install"
29
+ result = ShellIntegration.install("fish", env: CLI.env, force: force)
30
+ out.puts fish_install_message(result.fetch(:action), result.fetch(:path))
31
+ out.puts fish_completions_install_message(result.fetch(:completions_action), result.fetch(:completions_path))
32
+ out.puts "Restart fish to load the integration in this terminal: exec fish"
33
+ CLI.record_outcome(Outcome.new(exit_code: 0))
34
+ when "uninstall"
35
+ result = ShellIntegration.uninstall("fish", env: CLI.env, force: force)
36
+ out.puts fish_uninstall_message(result.fetch(:action), result.fetch(:path))
37
+ out.puts fish_completions_uninstall_message(result.fetch(:completions_action), result.fetch(:completions_path))
38
+ CLI.record_outcome(Outcome.new(exit_code: 0))
39
+ when "path"
40
+ out.puts "Function: #{ShellIntegration.path_for("fish", env: CLI.env)}"
41
+ out.puts "Completions: #{ShellIntegration.completions_path_for("fish", env: CLI.env)}"
42
+ CLI.record_outcome(Outcome.new(exit_code: 0))
43
+ else
44
+ err.puts "Usage: repo-tender shell fish [install|uninstall|path]"
45
+ CLI.record_outcome(Outcome.new(exit_code: 1))
46
+ end
47
+ rescue => e
48
+ err.puts "repo-tender shell fish: #{e.message}"
49
+ CLI.record_outcome(Outcome.new(exit_code: 1))
50
+ end
51
+
52
+ private
53
+
54
+ def fish_install_message(action, path)
55
+ case action
56
+ when :unchanged then "Fish integration already installed: #{path}"
57
+ when :updated then "Updated fish integration: #{path}"
58
+ else "Installed fish integration: #{path}"
59
+ end
60
+ end
61
+
62
+ def fish_uninstall_message(action, path)
63
+ case action
64
+ when :missing then "Fish integration was not installed: #{path}"
65
+ else "Removed fish integration: #{path}"
66
+ end
67
+ end
68
+
69
+ def fish_completions_install_message(action, path)
70
+ case action
71
+ when :unchanged then "Fish completions already installed: #{path}"
72
+ when :updated then "Updated fish completions: #{path}"
73
+ else "Installed fish completions: #{path}"
74
+ end
75
+ end
76
+
77
+ def fish_completions_uninstall_message(action, path)
78
+ case action
79
+ when :missing then "Fish completions were not installed: #{path}"
80
+ else "Removed fish completions: #{path}"
81
+ end
82
+ end
83
+ end
84
+
85
+ class Complete < Dry::CLI::Command
86
+ desc "Print completion candidates"
87
+ argument :kind, required: true, desc: "Completion kind"
88
+ argument :extra, type: :array, required: false, desc: "Extra args for completion"
89
+
90
+ def call(kind:, extra: [], **)
91
+ case kind
92
+ when "checkouts"
93
+ begin
94
+ paths = CLI.make_paths
95
+ config = Config::Store.load(paths.config_file).success
96
+ Nav.scan(config.base_dir).each { |e| out.puts e[:target] }
97
+ rescue
98
+ # Never raise on a completion call — missing/unparseable config is fine.
99
+ end
100
+ CLI.record_outcome(Outcome.new(exit_code: 0))
101
+ when "shells"
102
+ out.puts "fish"
103
+ CLI.record_outcome(Outcome.new(exit_code: 0))
104
+ else
105
+ err.puts "Usage: repo-tender shell complete checkouts|shells"
106
+ CLI.record_outcome(Outcome.new(exit_code: 1))
107
+ end
108
+ rescue => e
109
+ err.puts "repo-tender shell complete: #{e.message}"
110
+ CLI.record_outcome(Outcome.new(exit_code: 1))
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ RepoTender::CLI::Registry.register "shell" do |prefix|
118
+ prefix.register "init", RepoTender::CLI::Shell::Init
119
+ prefix.register "fish", RepoTender::CLI::Shell::Fish
120
+ prefix.register "complete", RepoTender::CLI::Shell::Complete
121
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
- require "repo_tender/cli"
5
4
  require "repo_tender/ui/mode"
6
5
  require "repo_tender/cli/options"
7
6
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/monads"
4
- require "repo_tender/cli"
5
4
  require "repo_tender/cli/repo" # for Repo::Helpers.parse_ref
6
5
  require "repo_tender/cli/options"
7
6
  require "repo_tender/ui/mode"
@@ -44,6 +43,19 @@ module RepoTender
44
43
  # the next spawn. No-op when the log is missing or
45
44
  # under-threshold (sync tests in G4 stay green).
46
45
  rotate_plist_logs(paths)
46
+
47
+ # Resolved BEFORE the scoping block: the "scoping sync to:"
48
+ # notice below is prose, and must be suppressed in JSON mode
49
+ # for the same reason the summary line is. Resolution depends
50
+ # only on flags/env/out, so it is safe this early.
51
+ mode = UI::Mode.resolve(
52
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
53
+ env: CLI.env,
54
+ out: out
55
+ )
56
+ # Every line of --json output must parse as one JSON object.
57
+ human = mode.format != :json
58
+
47
59
  if repo
48
60
  target = scope_target(repo)
49
61
  return fail_with(self, "invalid repo reference: #{repo.inspect} (expected host/owner/name)") if target.failure?
@@ -58,14 +70,9 @@ module RepoTender
58
70
  # exactly the G4 "other repo gets no state row" test
59
71
  # path, so we explicitly empty orgs here).
60
72
  config = Config::Store.with(config, repos: [found], orgs: [])
61
- out.puts "scoping sync to: #{Repo::Helpers.format_ref(found)}"
73
+ out.puts "scoping sync to: #{Repo::Helpers.format_ref(found)}" if human
62
74
  end
63
75
 
64
- mode = UI::Mode.resolve(
65
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
66
- env: CLI.env,
67
- out: out
68
- )
69
76
  reporter = if mode.format == :json
70
77
  UI::JsonReporter.new(out)
71
78
  elsif mode.animate
@@ -74,14 +81,19 @@ module RepoTender
74
81
  UI::PlainReporter.new(out, mode: mode)
75
82
  end
76
83
 
77
- result = RepoTender::Sync::Engine.new(reporter: reporter).call(config: config, paths: paths)
84
+ # prune only on a full sweep. With --repo the config above was
85
+ # narrowed to one repo, so pruning would delete the state rows
86
+ # for every OTHER tracked repo.
87
+ result = RepoTender::Sync::Engine.new(reporter: reporter)
88
+ .call(config: config, paths: paths, prune: repo.nil?)
78
89
  if result.failure?
79
90
  return fail_with(self, "sync failed: #{format_failure(result.failure)}")
80
91
  end
81
92
 
82
- new_state = result.success
83
- n = new_state.repos.size
84
- out.puts "synced #{n} repo(s)"
93
+ report = result.success
94
+ # JsonReporter already emits a run_finished event carrying the
95
+ # summary, so suppressing the prose line loses nothing.
96
+ out.puts "synced #{report.processed} repo(s)" if human
85
97
  CLI.record_outcome(Outcome.new(exit_code: 0))
86
98
  end
87
99