space-architect 8.0.0 → 9.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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +53 -243
  4. data/lib/space_architect/bug_report.rb +2 -1
  5. data/lib/space_architect/cli/architect.rb +20 -7
  6. data/lib/space_architect/cli.rb +9 -14
  7. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  8. data/lib/space_architect/session_sync/cursor.rb +1 -1
  9. data/lib/space_architect/session_sync/plist.rb +1 -1
  10. data/lib/{space_core → space_architect}/version.rb +2 -2
  11. data/lib/space_architect.rb +1 -1
  12. data/skill/architect/SKILL.md +3 -2
  13. data/skill/architect-vocabulary/SKILL.md +2 -2
  14. metadata +18 -177
  15. data/exe/space +0 -13
  16. data/exe/src +0 -13
  17. data/lib/space_architect/cli/space.rb +0 -29
  18. data/lib/space_architect/cli/src.rb +0 -38
  19. data/lib/space_core/atomic_write.rb +0 -21
  20. data/lib/space_core/cli/base_command.rb +0 -19
  21. data/lib/space_core/cli/build.rb +0 -27
  22. data/lib/space_core/cli/config.rb +0 -49
  23. data/lib/space_core/cli/current.rb +0 -16
  24. data/lib/space_core/cli/help.rb +0 -195
  25. data/lib/space_core/cli/helpers.rb +0 -115
  26. data/lib/space_core/cli/init.rb +0 -29
  27. data/lib/space_core/cli/list.rb +0 -24
  28. data/lib/space_core/cli/loop_status.rb +0 -47
  29. data/lib/space_core/cli/new.rb +0 -38
  30. data/lib/space_core/cli/pack.rb +0 -29
  31. data/lib/space_core/cli/path.rb +0 -16
  32. data/lib/space_core/cli/repeatable_options.rb +0 -84
  33. data/lib/space_core/cli/repo.rb +0 -76
  34. data/lib/space_core/cli/run.rb +0 -44
  35. data/lib/space_core/cli/shell.rb +0 -125
  36. data/lib/space_core/cli/show.rb +0 -21
  37. data/lib/space_core/cli/status.rb +0 -74
  38. data/lib/space_core/cli/use.rb +0 -17
  39. data/lib/space_core/cli.rb +0 -177
  40. data/lib/space_core/commands.rb +0 -51
  41. data/lib/space_core/config.rb +0 -152
  42. data/lib/space_core/errors.rb +0 -14
  43. data/lib/space_core/git_client.rb +0 -49
  44. data/lib/space_core/mise_client.rb +0 -37
  45. data/lib/space_core/oci_builder.rb +0 -56
  46. data/lib/space_core/oci_packer.rb +0 -103
  47. data/lib/space_core/oci_runner.rb +0 -89
  48. data/lib/space_core/paths.rb +0 -58
  49. data/lib/space_core/repo_reference.rb +0 -19
  50. data/lib/space_core/repo_resolver.rb +0 -167
  51. data/lib/space_core/shell_integration.rb +0 -438
  52. data/lib/space_core/slugger.rb +0 -16
  53. data/lib/space_core/space.rb +0 -174
  54. data/lib/space_core/space_store.rb +0 -319
  55. data/lib/space_core/state.rb +0 -86
  56. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  57. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  58. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  59. data/lib/space_core/terminal.rb +0 -161
  60. data/lib/space_core/warnings.rb +0 -13
  61. data/lib/space_core/xdg.rb +0 -33
  62. data/lib/space_core.rb +0 -26
  63. data/lib/space_src/cli/clone.rb +0 -54
  64. data/lib/space_src/cli/config.rb +0 -65
  65. data/lib/space_src/cli/daemon.rb +0 -362
  66. data/lib/space_src/cli/options.rb +0 -21
  67. data/lib/space_src/cli/org.rb +0 -199
  68. data/lib/space_src/cli/repo.rb +0 -169
  69. data/lib/space_src/cli/shell.rb +0 -121
  70. data/lib/space_src/cli/status.rb +0 -75
  71. data/lib/space_src/cli/sync.rb +0 -161
  72. data/lib/space_src/cli.rb +0 -159
  73. data/lib/space_src/cloner.rb +0 -77
  74. data/lib/space_src/config/contract.rb +0 -54
  75. data/lib/space_src/config/duration.rb +0 -79
  76. data/lib/space_src/config/model.rb +0 -49
  77. data/lib/space_src/config/store.rb +0 -156
  78. data/lib/space_src/forge/client.rb +0 -31
  79. data/lib/space_src/forge/github.rb +0 -98
  80. data/lib/space_src/launchd/agent.rb +0 -195
  81. data/lib/space_src/launchd/plist.rb +0 -129
  82. data/lib/space_src/log_rotator.rb +0 -46
  83. data/lib/space_src/migration.rb +0 -43
  84. data/lib/space_src/nav.rb +0 -99
  85. data/lib/space_src/paths.rb +0 -72
  86. data/lib/space_src/scm/client.rb +0 -87
  87. data/lib/space_src/scm/git.rb +0 -232
  88. data/lib/space_src/scm/status.rb +0 -24
  89. data/lib/space_src/shell.rb +0 -90
  90. data/lib/space_src/shell_integration.rb +0 -321
  91. data/lib/space_src/state/lock.rb +0 -59
  92. data/lib/space_src/state/store.rb +0 -140
  93. data/lib/space_src/sync/engine.rb +0 -503
  94. data/lib/space_src/sync/repo_plan.rb +0 -215
  95. data/lib/space_src/sync/report.rb +0 -18
  96. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  97. data/lib/space_src/ui/json_reporter.rb +0 -39
  98. data/lib/space_src/ui/mode.rb +0 -68
  99. data/lib/space_src/ui/plain_reporter.rb +0 -53
  100. data/lib/space_src/ui/reporter.rb +0 -48
  101. data/lib/space_src/version.rb +0 -7
  102. data/lib/space_src.rb +0 -38
@@ -1,215 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
- require "dry/monads"
5
- require "space_src/scm/client"
6
- require "space_src/scm/status"
7
-
8
- module Space::Src
9
- module Sync
10
- # Pure-ish evergreen evaluation. Given a RepoRef + on-disk path +
11
- # SCM client + refresh_interval, observe the repo and decide an
12
- # action. Returns a Plan (Data) carrying {action, status, reason}.
13
- #
14
- # Per Slice 2 PRD §3.3 + gate G2 seam, the plan is the *decision*
15
- # half; the engine is the *execution* half. The plan only uses
16
- # read-side SCM methods (`status`, `current_branch`,
17
- # `default_branch`, `last_fetch_at`) plus `fetch` when not-fresh
18
- # (a network observation). It never mutates branches.
19
- #
20
- # Action ↔ status mapping (per Slice 2 gates):
21
- #
22
- # :clone → status "missing" (before action) / "clean" (after)
23
- # :fast_forward → status "clean" (after)
24
- # :switch → status "clean" (after; the source status was wrong_branch/detached)
25
- # :skip_fresh → status "clean"
26
- # :up_to_date → status "clean"
27
- # :sync_empty → status "clean" (empty clone of empty/unborn remote)
28
- # :report_dirty → status "dirty"
29
- # :report_diverged → status "diverged"
30
- # :report_wrong_branch → status "wrong_branch"
31
- # :report_detached → status "detached"
32
- # :report_error → status "error" (probe Failure translation; not in the spec's
33
- # nine actions but required by G8)
34
- #
35
- # The plan always returns Success(Plan). Any SCM-probe Failure is
36
- # translated to a :report_error action so the engine has a uniform
37
- # dispatch surface. The plan never raises.
38
- module RepoPlan
39
- extend Dry::Monads[:result]
40
-
41
- Plan = Data.define(:action, :status, :reason) do
42
- def initialize(action:, status:, reason: nil)
43
- super
44
- end
45
- end
46
-
47
- def self.call(repo_ref:, path:, scm:, refresh_interval:, now: Time.now)
48
- # 1. Present? (PRD §3.3 step 1; gate G6)
49
- unless Dir.exist?(path)
50
- return Success(Plan.new(
51
- action: :clone,
52
- status: "missing",
53
- reason: "path does not exist: #{path}"
54
- ))
55
- end
56
-
57
- # 2. Working-tree status (porcelain v2 with branch.ab).
58
- # This single call gives us clean/dirty + the ahead/behind
59
- # numbers we need for the behind check (disagreement #2:
60
- # we use SCM::Status#behind / #ahead, not a new SCM
61
- # boundary, since the BOUNDARIES list only permits adding
62
- # `switch` to SCM::Client).
63
- status_result = scm.status(path)
64
- if status_result.failure?
65
- return report_error(repo_ref, "status probe failed: #{status_result.failure.inspect}")
66
- end
67
- scm_status = status_result.success
68
-
69
- # 2b. Unborn (empty) repo? No commits exist anywhere on the
70
- # local clone. Skip the `current_branch` / `default_branch`
71
- # probes — both would succeed but `default_branch` calls
72
- # `git remote set-head origin -a` which exits non-zero on an
73
- # empty remote ("Cannot determine remote HEAD"), turning a
74
- # valid empty clone into a false :report_error. Delegate the
75
- # remote-has-commits? check to the engine's sync_empty call.
76
- if scm_status.unborn?
77
- if scm_status.clean?
78
- return Success(Plan.new(
79
- action: :sync_empty,
80
- status: "clean",
81
- reason: "empty repository (no commits yet)"
82
- ))
83
- else
84
- return Success(Plan.new(
85
- action: :report_dirty,
86
- status: "dirty",
87
- reason: "empty repository with uncommitted local files; not touching"
88
- ))
89
- end
90
- end
91
-
92
- # 3. Current branch + default branch.
93
- current_result = scm.current_branch(path)
94
- if current_result.failure?
95
- return report_error(repo_ref, "current_branch probe failed: #{current_result.failure.inspect}")
96
- end
97
- current = current_result.success
98
-
99
- default_result = scm.default_branch(path)
100
- if default_result.failure?
101
- return report_error(repo_ref, "default_branch probe failed: #{default_result.failure.inspect}")
102
- end
103
- default_branch = default_result.success
104
-
105
- # 4. Detached or wrong branch? (gate G5)
106
- if current.nil?
107
- if scm_status.clean?
108
- return Success(Plan.new(
109
- action: :switch,
110
- status: "detached",
111
- reason: "detached HEAD on a clean tree; switching to #{default_branch}"
112
- ))
113
- else
114
- return Success(Plan.new(
115
- action: :report_detached,
116
- status: "detached",
117
- reason: "detached HEAD with a dirty tree; not switching"
118
- ))
119
- end
120
- elsif current != default_branch
121
- if scm_status.clean?
122
- return Success(Plan.new(
123
- action: :switch,
124
- status: "wrong_branch",
125
- reason: "on branch #{current} (default is #{default_branch}); switching"
126
- ))
127
- else
128
- return Success(Plan.new(
129
- action: :report_wrong_branch,
130
- status: "wrong_branch",
131
- reason: "on branch #{current} (default is #{default_branch}) with a dirty tree; not switching"
132
- ))
133
- end
134
- end
135
-
136
- # 5. Clean? (gate G3 — dirty repos are NEVER touched)
137
- unless scm_status.clean?
138
- return Success(Plan.new(
139
- action: :report_dirty,
140
- status: "dirty",
141
- reason: "working tree has changes (#{scm_status.entries.length} entry/entries)"
142
- ))
143
- end
144
-
145
- # 6. Fresh? (PRD §3.3 step 4; gate G2; PHASE-0 ruling: nil /
146
- # stale / Failure all → stale; never skip on unreadable/
147
- # absent FETCH_HEAD)
148
- last_fetch = scm.last_fetch_at(path)
149
- if last_fetch.success?
150
- t = last_fetch.success
151
- if t && (now - t) <= refresh_interval
152
- return Success(Plan.new(
153
- action: :skip_fresh,
154
- status: "clean",
155
- reason: "fetched at #{t.iso8601} within refresh_interval=#{refresh_interval}s"
156
- ))
157
- end
158
- end
159
-
160
- # 7. Not fresh → fetch + re-check status for behind / diverged /
161
- # up_to_date. After fetch, `origin/<default>` is up to date
162
- # with the remote, so the next `scm.status` call's
163
- # `behind`/`ahead` (porcelain v2 `# branch.ab`) are current.
164
- fetch_result = scm.fetch(path)
165
- if fetch_result.failure?
166
- return report_error(repo_ref, "fetch failed: #{fetch_result.failure.inspect}")
167
- end
168
-
169
- re_status = scm.status(path)
170
- if re_status.failure?
171
- return report_error(repo_ref, "status re-probe after fetch failed: #{re_status.failure.inspect}")
172
- end
173
- behind = re_status.success.behind
174
- ahead = re_status.success.ahead
175
-
176
- # 8. Diverged? (gate G4 — never reset, never auto-resolve)
177
- if ahead > 0
178
- return Success(Plan.new(
179
- action: :report_diverged,
180
- status: "diverged",
181
- reason: "local is #{ahead} commit(s) ahead of origin/#{default_branch} (no reset --hard)"
182
- ))
183
- end
184
-
185
- # 9. Behind? (gate G1)
186
- if behind > 0
187
- return Success(Plan.new(
188
- action: :fast_forward,
189
- status: "clean",
190
- reason: "behind by #{behind} commit(s); merging --ff-only"
191
- ))
192
- end
193
-
194
- # 10. Up to date (no-op — just a state write at the engine layer)
195
- Success(Plan.new(
196
- action: :up_to_date,
197
- status: "clean",
198
- reason: "up to date with origin/#{default_branch}"
199
- ))
200
- end
201
-
202
- def self.report_error(repo_ref, reason)
203
- Success(Plan.new(
204
- action: :report_error,
205
- status: "error",
206
- reason: "#{repo_key(repo_ref)}: #{reason}"
207
- ))
208
- end
209
-
210
- def self.repo_key(r)
211
- "#{r.host}/#{r.owner}/#{r.name}"
212
- end
213
- end
214
- end
215
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Space::Src
4
- module Sync
5
- # What one sync pass did.
6
- #
7
- # `state` is the merged whole-state that gets written to disk: it
8
- # carries a row for every repo ever synced, because build_new_state
9
- # starts from the previous state and merges this run's outcomes into
10
- # it. `processed` counts only the repos this run actually touched.
11
- #
12
- # The two diverge whenever the run is scoped (`sync --repo`) or the
13
- # state file already holds rows from earlier runs — which is why
14
- # callers wanting "how many repos did this run handle?" must read
15
- # `processed` and never `state.repos.size`.
16
- Report = Data.define(:state, :processed)
17
- end
18
- end
@@ -1,280 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pastel"
4
- require "tty-cursor"
5
-
6
- module Space::Src
7
- module UI
8
- # Compact, single-line live progress renderer for `sync`.
9
- # Driven by one render-loop fiber spawned as a child of the engine task
10
- # via `attach(task)` — NO Ruby Thread.
11
- #
12
- # Two phases under one attach/detach (GS6):
13
- #
14
- # Phase 1 — Listing: fires between listing_started and listing_finished.
15
- # Live status line shows "listing N orgs… ✓ K done". As each org
16
- # completes, a persistent line is emitted (org name + count).
17
- #
18
- # Phase 2 — Sweep: fires after run_started through run_finished.
19
- # Reverts to the compact repo counter (synced X/N + tallies).
20
- #
21
- # Output model:
22
- # - One live status line, rewritten in place via \r + \e[K.
23
- # - Persistent scrollback lines for listing phase (one per org) and
24
- # for NON-CLEAN repos only in sweep phase.
25
- # - Total output: O(orgs + non_clean + failed + constant).
26
- #
27
- # Invariants:
28
- # - The render fiber is the sole writer to `out`; worker fibers only
29
- # mutate tally/queue state via the reporter event methods.
30
- # - `Kernel#sleep` inside the render fiber yields to the reactor
31
- # (cooperative scheduling). Never Thread.new.
32
- # - On `^C`, the scheduler cancels the child render fiber; its `ensure`
33
- # block restores the cursor unconditionally.
34
- class InteractiveReporter
35
- FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
36
- ADDED_LIST_THRESHOLD = 10
37
- IN_FLIGHT_MAX_WIDTH = 40
38
-
39
- def initialize(out, mode:, cadence: 0.1)
40
- @out = out
41
- @pastel = Pastel.new(enabled: mode.color)
42
- @cadence = cadence
43
-
44
- # Listing phase state
45
- @org_total = 0
46
- @org_done = 0
47
- @pending_org_lines = []
48
-
49
- # Sweep phase state
50
- @total = 0
51
- @finished = 0
52
- @clean_count = 0
53
- @nonclean_count = 0
54
- @failed_count = 0
55
- @pending_lines = []
56
-
57
- # In-flight tracking (insertion-ordered: last entry = most-recently-started)
58
- @in_flight = {}
59
-
60
- # End-of-run breakdown state
61
- @action_counts = Hash.new(0)
62
- @total_commits = 0
63
- @added_repos = []
64
-
65
- @frame_idx = 0
66
- @phase = :listing # :listing | :sweep
67
- @done = false
68
- @render_task = nil
69
- end
70
-
71
- def attach(task)
72
- @render_task = task.async { render_loop }
73
- end
74
-
75
- def detach
76
- @done = true
77
- @render_task&.wait
78
- @render_task = nil
79
- end
80
-
81
- # --- Listing phase events ---
82
-
83
- def listing_started(total:)
84
- @org_total = total
85
- @phase = :listing
86
- end
87
-
88
- def org_listed(ref, count:)
89
- @org_done += 1
90
- @pending_org_lines << if count
91
- "#{@pastel.green("✓")} #{ref.name} #{count} repo(s)"
92
- else
93
- "#{@pastel.red("✗")} #{ref.name} FAILED"
94
- end
95
- end
96
-
97
- def listing_finished
98
- # Phase transition handled by run_started
99
- end
100
-
101
- # --- Sweep phase events ---
102
-
103
- def run_started(total:)
104
- @total = total
105
- @phase = :sweep
106
- end
107
-
108
- def repo_started(ref)
109
- @in_flight[ref] = "checking"
110
- end
111
-
112
- def repo_phase(ref, phase)
113
- return unless @in_flight.key?(ref)
114
- @in_flight[ref] = case phase
115
- when :cloning then "cloning"
116
- when :fast_forwarding then "fast-forwarding"
117
- when :switching then "switching"
118
- else @in_flight[ref]
119
- end
120
- end
121
-
122
- def repo_finished(ref, status, action:, commits: 0)
123
- @in_flight.delete(ref)
124
- @finished += 1
125
- @action_counts[action] += 1
126
- @total_commits += commits
127
- @added_repos << ref if action == :cloned
128
- if status.to_s == "clean"
129
- @clean_count += 1
130
- else
131
- @nonclean_count += 1
132
- @pending_lines << "#{@pastel.yellow("⚠")} #{ref} #{status}"
133
- end
134
- end
135
-
136
- def repo_failed(ref, error)
137
- @in_flight.delete(ref)
138
- @finished += 1
139
- @failed_count += 1
140
- @action_counts[:error] += 1
141
- @pending_lines << "#{@pastel.red("✗")} #{ref} #{error}"
142
- end
143
-
144
- def run_finished(summary) = nil
145
-
146
- private
147
-
148
- def render_loop
149
- @out.write(TTY::Cursor.hide)
150
- @out.flush
151
-
152
- loop do
153
- render_tick
154
- @frame_idx += 1
155
- break if @done
156
- sleep @cadence
157
- end
158
- ensure
159
- # Flush any remaining org lines (listing phase may have ended without
160
- # a final tick draining them).
161
- pending_org = @pending_org_lines.slice!(0, @pending_org_lines.length)
162
- pending = @pending_lines.slice!(0, @pending_lines.length)
163
- @out.write("\r\e[K")
164
- pending_org.each { |line| @out.write("#{line}\n") }
165
- pending.each { |line| @out.write("#{line}\n") }
166
- @out.write("#{build_summary_line}\n")
167
- breakdown = build_breakdown_line
168
- @out.write("#{breakdown}\n") unless breakdown.empty?
169
- added = build_added_repos_block
170
- @out.write(added) unless added.empty?
171
- @out.write(TTY::Cursor.show)
172
- @out.flush
173
- end
174
-
175
- def render_tick
176
- if @phase == :listing
177
- render_listing_tick
178
- else
179
- render_sweep_tick
180
- end
181
- end
182
-
183
- def render_listing_tick
184
- pending = @pending_org_lines.slice!(0, @pending_org_lines.length)
185
- if pending.any?
186
- @out.write("\r\e[K")
187
- pending.each { |line| @out.write("#{line}\n") }
188
- end
189
- frame = @pastel.cyan(FRAMES[@frame_idx % FRAMES.length])
190
- @out.write("\r\e[K#{frame} listing #{@org_total} org(s)… #{@pastel.green("✓")} #{@org_done} done")
191
- end
192
-
193
- def render_sweep_tick
194
- org_pending = @pending_org_lines.slice!(0, @pending_org_lines.length)
195
- if org_pending.any?
196
- @out.write("\r\e[K")
197
- org_pending.each { |line| @out.write("#{line}\n") }
198
- end
199
- pending = @pending_lines.slice!(0, @pending_lines.length)
200
- if pending.any?
201
- @out.write("\r\e[K")
202
- pending.each { |line| @out.write("#{line}\n") }
203
- end
204
- @out.write("\r\e[K#{build_status_line}")
205
- end
206
-
207
- def build_status_line
208
- frame = @pastel.cyan(FRAMES[@frame_idx % FRAMES.length])
209
- # Right-justify every counter to the digit-width of @total (the cap on
210
- # all of them) so the in-flight suffix sits at a fixed column instead of
211
- # drifting right as counts cross digit boundaries. Padding is invisible
212
- # leading spaces.
213
- w = @total.to_s.length
214
- base = "#{frame} synced #{@finished.to_s.rjust(w)}/#{@total} #{@pastel.green("✓")} #{@clean_count.to_s.rjust(w)} #{@pastel.yellow("⚠")} #{@nonclean_count.to_s.rjust(w)} #{@pastel.red("✗")} #{@failed_count.to_s.rjust(w)}"
215
- "#{base}#{build_in_flight_suffix}"
216
- end
217
-
218
- def build_summary_line
219
- "synced #{@finished}/#{@total} #{@pastel.green("✓")} #{@clean_count} clean #{@pastel.yellow("⚠")} #{@nonclean_count} non-clean #{@pastel.red("✗")} #{@failed_count} failed"
220
- end
221
-
222
- def build_in_flight_suffix
223
- return "" if @in_flight.empty?
224
- ref, verb = @in_flight.to_a.last
225
- short = ref.to_s.split("/", 2).last.to_s
226
- short = short[0, IN_FLIGHT_MAX_WIDTH]
227
- " · #{verb} #{short}"
228
- end
229
-
230
- def build_breakdown_line
231
- parts = []
232
- if (n = @action_counts[:cloned]) > 0
233
- parts << "cloned #{n}"
234
- end
235
- if (n = @action_counts[:fast_forwarded]) > 0
236
- commit_str = (@total_commits > 0) ? " (#{@total_commits} commit#{"s" unless @total_commits == 1})" : ""
237
- parts << "fast-forwarded #{n}#{commit_str}"
238
- end
239
- if (n = @action_counts[:up_to_date]) > 0
240
- parts << "up-to-date #{n}"
241
- end
242
- if (n = @action_counts[:switched]) > 0
243
- parts << "switched #{n}"
244
- end
245
- if (n = @action_counts[:dirty]) > 0
246
- parts << "dirty #{n}"
247
- end
248
- if (n = @action_counts[:diverged]) > 0
249
- parts << "diverged #{n}"
250
- end
251
- if (n = @action_counts[:wrong_branch]) > 0
252
- parts << "wrong-branch #{n}"
253
- end
254
- if (n = @action_counts[:detached]) > 0
255
- parts << "detached #{n}"
256
- end
257
- error_n = @action_counts[:error]
258
- if error_n > 0
259
- parts << "#{(error_n == 1) ? "error" : "errors"} #{error_n}"
260
- end
261
- parts.join(" ")
262
- end
263
-
264
- def build_added_repos_block
265
- return "" if @added_repos.empty?
266
- count = @added_repos.size
267
- if count > ADDED_LIST_THRESHOLD
268
- "added #{count} repos\n"
269
- else
270
- lines = ["added #{count} #{(count == 1) ? "repo" : "repos"}:"]
271
- @added_repos.each do |ref|
272
- short = ref.to_s.split("/", 2).last.to_s
273
- lines << " #{short}"
274
- end
275
- lines.join("\n") + "\n"
276
- end
277
- end
278
- end
279
- end
280
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "time"
5
-
6
- module Space::Src
7
- module UI
8
- # Emits one JSON object per event line (12-factor style) to `out`.
9
- # Every object carries at minimum: "event", "t" (ISO8601 timestamp),
10
- # plus event-specific keys (ref, status, error, total, summary).
11
- # @out.sync = true ensures output is immediate on non-TTY pipes (GS5).
12
- class JsonReporter
13
- def initialize(out)
14
- @out = out
15
- @out.sync = true
16
- end
17
-
18
- def attach(task) = nil
19
- def detach = nil
20
-
21
- def listing_started(total:) = emit(event: "listing_started", total: total)
22
- def org_listed(ref, count:) = emit(event: "org_listed", org: ref.name, count: count)
23
- def listing_finished = emit(event: "listing_finished")
24
-
25
- def run_started(total:) = emit(event: "run_started", total: total)
26
- def repo_started(ref) = emit(event: "repo_started", ref: ref)
27
- def repo_phase(ref, phase) = emit(event: "repo_phase", ref: ref, phase: phase)
28
- def repo_finished(ref, status, action:, commits: 0) = emit(event: "repo_finished", ref: ref, status: status, action: action, commits: commits)
29
- def repo_failed(ref, error) = emit(event: "repo_failed", ref: ref, error: error.to_s)
30
- def run_finished(summary) = emit(event: "run_finished", summary: summary)
31
-
32
- private
33
-
34
- def emit(payload)
35
- @out.puts JSON.generate(payload.merge(t: Time.now.iso8601))
36
- end
37
- end
38
- end
39
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/struct"
4
- require "dry/types"
5
-
6
- module Space::Src
7
- module UI
8
- Types = Dry.Types()
9
-
10
- # Resolved output mode — immutable dry-struct constructed once per command
11
- # invocation from (flags, env, out.tty?) using precedence: flag > env > autodetect.
12
- #
13
- # Frozen contract (PRD §3.1):
14
- #
15
- # format: :pretty | :plain | :json
16
- # color: true | false
17
- # animate: true | false (always false in Slice A; Slice B gates on this)
18
- # quiet: true | false
19
- #
20
- # Color precedence: --no-color (flag) > CLICOLOR_FORCE (env-force) >
21
- # NO_COLOR/TERM=dumb/non-:pretty/non-TTY (env+autodetect).
22
- class Mode < Dry::Struct
23
- FORMATS = %i[pretty plain json].freeze
24
-
25
- attribute :color, Types::Bool
26
- attribute :animate, Types::Bool
27
- attribute :quiet, Types::Bool
28
- attribute :format, Types::Symbol.constrained(included_in: FORMATS)
29
-
30
- # @param flags [Hash] CLI flags: :plain, :json, :no_color, :quiet (truthy = set)
31
- # @param env [Hash] environment hash (use CLI.env for the injectable test seam)
32
- # @param out [IO] the output stream; tested with out.tty?
33
- def self.resolve(flags:, env:, out:)
34
- json = !!flags[:json]
35
- plain = !!flags[:plain]
36
- no_color = !!flags[:no_color]
37
- quiet = !!flags[:quiet]
38
-
39
- format = if json
40
- :json
41
- elsif plain || !out.tty?
42
- :plain
43
- else
44
- :pretty
45
- end
46
-
47
- no_color_env = env["NO_COLOR"] && !env["NO_COLOR"].empty?
48
- clicolor_force = env["CLICOLOR_FORCE"] && !env["CLICOLOR_FORCE"].empty?
49
- term_dumb = env["TERM"] == "dumb"
50
-
51
- color = if no_color
52
- false
53
- elsif clicolor_force
54
- true
55
- elsif no_color_env || term_dumb || format != :pretty || !out.tty?
56
- false
57
- else
58
- true
59
- end
60
-
61
- ci = env["CI"] && !env["CI"].empty?
62
- animate = format == :pretty && out.tty? && !quiet && !ci
63
-
64
- new(color: color, animate: animate, quiet: quiet, format: format)
65
- end
66
- end
67
- end
68
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Space::Src
4
- module UI
5
- # Emits one tab-separated line per terminal repo event, ANSI-free always.
6
- # repo_finished → "ref\tstatus"; repo_failed → "ref\tFAILED\terror".
7
- # org_listed → "listed: ref.name\tN repos" (or "listed: ref.name\tFAILED" on failure).
8
- # Both write to the same `out` stream (G4 choice: no separate stderr stream
9
- # in Slice A; FAILED marker distinguishes errors).
10
- # @out.sync = true ensures output is immediate on non-TTY pipes (GS5).
11
- class PlainReporter
12
- def initialize(out, mode: nil)
13
- @out = out
14
- @out.sync = true
15
- end
16
-
17
- def attach(task) = nil
18
- def detach = nil
19
-
20
- def listing_started(total:)
21
- @out.puts "listing: #{total} org(s)"
22
- end
23
-
24
- def org_listed(ref, count:)
25
- if count
26
- @out.puts "listed: #{ref.name}\t#{count} repo(s)"
27
- else
28
- @out.puts "listed: #{ref.name}\tFAILED"
29
- end
30
- end
31
-
32
- def listing_finished = nil
33
-
34
- def run_started(total:)
35
- @out.puts "starting: #{total} repo(s)"
36
- end
37
-
38
- def repo_started(ref) = nil
39
-
40
- def repo_phase(ref, phase) = nil
41
-
42
- def repo_finished(ref, status, action:, commits: 0)
43
- @out.puts "#{ref}\t#{status}"
44
- end
45
-
46
- def repo_failed(ref, error)
47
- @out.puts "#{ref}\tFAILED\t#{error}"
48
- end
49
-
50
- def run_finished(summary) = nil
51
- end
52
- end
53
- end