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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +53 -243
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +20 -7
- data/lib/space_architect/cli.rb +9 -14
- data/lib/space_architect/session_sync/bin_path.rb +1 -1
- data/lib/space_architect/session_sync/cursor.rb +1 -1
- data/lib/space_architect/session_sync/plist.rb +1 -1
- data/lib/{space_core → space_architect}/version.rb +2 -2
- data/lib/space_architect.rb +1 -1
- data/skill/architect/SKILL.md +3 -2
- data/skill/architect-vocabulary/SKILL.md +2 -2
- metadata +18 -177
- data/exe/space +0 -13
- data/exe/src +0 -13
- data/lib/space_architect/cli/space.rb +0 -29
- data/lib/space_architect/cli/src.rb +0 -38
- data/lib/space_core/atomic_write.rb +0 -21
- data/lib/space_core/cli/base_command.rb +0 -19
- data/lib/space_core/cli/build.rb +0 -27
- data/lib/space_core/cli/config.rb +0 -49
- data/lib/space_core/cli/current.rb +0 -16
- data/lib/space_core/cli/help.rb +0 -195
- data/lib/space_core/cli/helpers.rb +0 -115
- data/lib/space_core/cli/init.rb +0 -29
- data/lib/space_core/cli/list.rb +0 -24
- data/lib/space_core/cli/loop_status.rb +0 -47
- data/lib/space_core/cli/new.rb +0 -38
- data/lib/space_core/cli/pack.rb +0 -29
- data/lib/space_core/cli/path.rb +0 -16
- data/lib/space_core/cli/repeatable_options.rb +0 -84
- data/lib/space_core/cli/repo.rb +0 -76
- data/lib/space_core/cli/run.rb +0 -44
- data/lib/space_core/cli/shell.rb +0 -125
- data/lib/space_core/cli/show.rb +0 -21
- data/lib/space_core/cli/status.rb +0 -74
- data/lib/space_core/cli/use.rb +0 -17
- data/lib/space_core/cli.rb +0 -177
- data/lib/space_core/commands.rb +0 -51
- data/lib/space_core/config.rb +0 -152
- data/lib/space_core/errors.rb +0 -14
- data/lib/space_core/git_client.rb +0 -49
- data/lib/space_core/mise_client.rb +0 -37
- data/lib/space_core/oci_builder.rb +0 -56
- data/lib/space_core/oci_packer.rb +0 -103
- data/lib/space_core/oci_runner.rb +0 -89
- data/lib/space_core/paths.rb +0 -58
- data/lib/space_core/repo_reference.rb +0 -19
- data/lib/space_core/repo_resolver.rb +0 -167
- data/lib/space_core/shell_integration.rb +0 -438
- data/lib/space_core/slugger.rb +0 -16
- data/lib/space_core/space.rb +0 -174
- data/lib/space_core/space_store.rb +0 -319
- data/lib/space_core/state.rb +0 -86
- data/lib/space_core/templates/oci/dockerfile.erb +0 -73
- data/lib/space_core/templates/oci/dockerignore.erb +0 -17
- data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
- data/lib/space_core/terminal.rb +0 -161
- data/lib/space_core/warnings.rb +0 -13
- data/lib/space_core/xdg.rb +0 -33
- data/lib/space_core.rb +0 -26
- data/lib/space_src/cli/clone.rb +0 -54
- data/lib/space_src/cli/config.rb +0 -65
- data/lib/space_src/cli/daemon.rb +0 -362
- data/lib/space_src/cli/options.rb +0 -21
- data/lib/space_src/cli/org.rb +0 -199
- data/lib/space_src/cli/repo.rb +0 -169
- data/lib/space_src/cli/shell.rb +0 -121
- data/lib/space_src/cli/status.rb +0 -75
- data/lib/space_src/cli/sync.rb +0 -161
- data/lib/space_src/cli.rb +0 -159
- data/lib/space_src/cloner.rb +0 -77
- data/lib/space_src/config/contract.rb +0 -54
- data/lib/space_src/config/duration.rb +0 -79
- data/lib/space_src/config/model.rb +0 -49
- data/lib/space_src/config/store.rb +0 -156
- data/lib/space_src/forge/client.rb +0 -31
- data/lib/space_src/forge/github.rb +0 -98
- data/lib/space_src/launchd/agent.rb +0 -195
- data/lib/space_src/launchd/plist.rb +0 -129
- data/lib/space_src/log_rotator.rb +0 -46
- data/lib/space_src/migration.rb +0 -43
- data/lib/space_src/nav.rb +0 -99
- data/lib/space_src/paths.rb +0 -72
- data/lib/space_src/scm/client.rb +0 -87
- data/lib/space_src/scm/git.rb +0 -232
- data/lib/space_src/scm/status.rb +0 -24
- data/lib/space_src/shell.rb +0 -90
- data/lib/space_src/shell_integration.rb +0 -321
- data/lib/space_src/state/lock.rb +0 -59
- data/lib/space_src/state/store.rb +0 -140
- data/lib/space_src/sync/engine.rb +0 -503
- data/lib/space_src/sync/repo_plan.rb +0 -215
- data/lib/space_src/sync/report.rb +0 -18
- data/lib/space_src/ui/interactive_reporter.rb +0 -280
- data/lib/space_src/ui/json_reporter.rb +0 -39
- data/lib/space_src/ui/mode.rb +0 -68
- data/lib/space_src/ui/plain_reporter.rb +0 -53
- data/lib/space_src/ui/reporter.rb +0 -48
- data/lib/space_src/version.rb +0 -7
- 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
|
data/lib/space_src/ui/mode.rb
DELETED
|
@@ -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
|