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,503 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "time"
|
|
5
|
-
require "async"
|
|
6
|
-
require "async/barrier"
|
|
7
|
-
require "async/semaphore"
|
|
8
|
-
require "dry/monads"
|
|
9
|
-
require "space_src/config/model"
|
|
10
|
-
require "space_src/scm/git"
|
|
11
|
-
require "space_src/forge/github"
|
|
12
|
-
require "space_src/state/store"
|
|
13
|
-
require "space_src/state/lock"
|
|
14
|
-
require "space_src/paths"
|
|
15
|
-
require "space_src/sync/repo_plan"
|
|
16
|
-
require "space_src/sync/report"
|
|
17
|
-
require "space_src/ui/reporter"
|
|
18
|
-
|
|
19
|
-
module Space::Src
|
|
20
|
-
module Sync
|
|
21
|
-
# The sync engine: one run that brings every tracked repo to the
|
|
22
|
-
# evergreen invariant (PRD §3.3). Splits observation (RepoPlan)
|
|
23
|
-
# from execution (this class). Bounded by Async::Semaphore
|
|
24
|
-
# (config.concurrency) inside one Sync{} block. A single repo's
|
|
25
|
-
# Failure does NOT abort the run — it is captured, the state row
|
|
26
|
-
# is written as status: error, and the run continues.
|
|
27
|
-
#
|
|
28
|
-
# Slice 2 gate wiring:
|
|
29
|
-
# G7 — Async::Semaphore(concurrency) bounds the in-flight count.
|
|
30
|
-
# G8 — every processed repo gets a state row; failures are
|
|
31
|
-
# recorded with status: error + last_error.
|
|
32
|
-
# G9 — second run on a fresh set performs no network calls
|
|
33
|
-
# (the :skip_fresh plan short-circuits the SCM.fetch).
|
|
34
|
-
# G10 — OrgRef expansion via the injected forge; an org-list
|
|
35
|
-
# Failure is recorded and does not abort the run.
|
|
36
|
-
#
|
|
37
|
-
# sync-startup gate wiring (GS1–GS6):
|
|
38
|
-
# GS1 — expand_orgs fans org listings out concurrently via
|
|
39
|
-
# Async::Barrier + Async::Semaphore(config.concurrency).
|
|
40
|
-
# GS2 — forge.check_authenticated called ONCE before fan-out;
|
|
41
|
-
# on auth Failure all orgs recorded failed, no list_org.
|
|
42
|
-
# GS3 — CF3 / G10 resilience preserved (per-org failure isolated,
|
|
43
|
-
# prior repo_count/last_listed_at preserved on failure).
|
|
44
|
-
# GS4 — reporter events: attach → listing_started → org_listed*
|
|
45
|
-
# → listing_finished → run_started → repo* → run_finished
|
|
46
|
-
# → detach.
|
|
47
|
-
class Engine
|
|
48
|
-
extend Dry::Monads[:result]
|
|
49
|
-
|
|
50
|
-
# `Success` and `Failure` are used inside the `call` instance
|
|
51
|
-
# method (via the `Sync{}` block). Per Slice 1 convention
|
|
52
|
-
# (see SCM::Client / SCM::Git / Shell), we use the fully-
|
|
53
|
-
# qualified `Dry::Monads::Success(...)` from inside instance
|
|
54
|
-
# methods. The `extend` is kept so class-level callers (tests,
|
|
55
|
-
# future helpers) can use the short form.
|
|
56
|
-
|
|
57
|
-
# Default clone URL: scp-like SSH form `git@<host>:<owner>/<name>.git`.
|
|
58
|
-
# SSH uses the user's configured SSH keys (default
|
|
59
|
-
# `~/.ssh/id_rsa`/whatever `~/.ssh/config` resolves) with no
|
|
60
|
-
# interactive `Username for 'https://github.com':` prompt — the
|
|
61
|
-
# field defect Slice 6 fixed (the previous HTTPS default made
|
|
62
|
-
# a missing-repo clone prompt for credentials). This is the
|
|
63
|
-
# seam the Slice 2 disagreement-#6 ruling anticipated ("legit
|
|
64
|
-
# future seam (ssh/token)"). No new config field is added in
|
|
65
|
-
# this slice — the transport flip is on the default builder
|
|
66
|
-
# only; tests can still inject a different builder (e.g.
|
|
67
|
-
# file:// for a local bare remote in the G6 missing-path test).
|
|
68
|
-
DEFAULT_URL_BUILDER = ->(ref) { "git@#{ref.host}:#{ref.owner}/#{ref.name}.git" }.freeze
|
|
69
|
-
|
|
70
|
-
def initialize(scm: SCM::Git.new, forge: Forge::GitHub.new,
|
|
71
|
-
clock: -> { Time.now }, url_builder: DEFAULT_URL_BUILDER,
|
|
72
|
-
reporter: Space::Src::UI::NullReporter.new)
|
|
73
|
-
@scm = scm
|
|
74
|
-
@forge = forge
|
|
75
|
-
@clock = clock
|
|
76
|
-
@url_builder = url_builder
|
|
77
|
-
@reporter = reporter
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Runs one sync pass.
|
|
81
|
-
# @param config [Config::Config] the validated config struct
|
|
82
|
-
# @param paths [Paths] the XDG paths object
|
|
83
|
-
# @param prune [Boolean] drop state rows for repos this run did
|
|
84
|
-
# not see. Only the CALLER can know whether `config` is the
|
|
85
|
-
# complete tracked set or a scoped subset — the engine cannot
|
|
86
|
-
# tell "user tracks one repo" from "user passed --repo" — so
|
|
87
|
-
# this is never inferred here. Defaults to false: never delete
|
|
88
|
-
# unless explicitly told to. Even when true, pruning is skipped
|
|
89
|
-
# if any org listing failed (see below).
|
|
90
|
-
# @return [Dry::Monads::Result<Report>] the merged state plus the
|
|
91
|
-
# count of repos this run processed. The two are distinct: state
|
|
92
|
-
# accumulates every repo ever synced, `processed` is this run's
|
|
93
|
-
# work (see Sync::Report).
|
|
94
|
-
def call(config:, paths:, prune: false)
|
|
95
|
-
Sync do |task|
|
|
96
|
-
semaphore = Async::Semaphore.new(config.concurrency, parent: task)
|
|
97
|
-
barrier = Async::Barrier.new
|
|
98
|
-
|
|
99
|
-
# Acquire an exclusive advisory lock on the sidecar lockfile
|
|
100
|
-
# BEFORE loading state. Held across the entire load→write span
|
|
101
|
-
# so an overlapping run never writes and cannot clobber the
|
|
102
|
-
# in-flight run's data (CF10). Non-blocking: if another run
|
|
103
|
-
# holds the lock we bail cleanly rather than blocking forever
|
|
104
|
-
# (a blocked launchd tick would pile up on every subsequent
|
|
105
|
-
# StartInterval fire).
|
|
106
|
-
lock_result = State::Lock.acquire(paths.state_file) do
|
|
107
|
-
# State is loaded once at the start (or initialized empty for
|
|
108
|
-
# a missing state.yaml). A new State object is built from
|
|
109
|
-
# the run's outcomes and written atomically at the end. Per-
|
|
110
|
-
# repo state rows that did not change are preserved.
|
|
111
|
-
state = State::Store.load(paths.state_file).success
|
|
112
|
-
now = @clock.call
|
|
113
|
-
|
|
114
|
-
# Attach the reporter before expansion so the render fiber is
|
|
115
|
-
# alive during listing (GS4: attach fires before listing_started).
|
|
116
|
-
@reporter.attach(task)
|
|
117
|
-
begin
|
|
118
|
-
# Phase 1: org expansion (concurrent; per-org failures isolated).
|
|
119
|
-
# CF3 (Slice 4 Lane 02) passes the prev state's org map so an
|
|
120
|
-
# org-list Failure can preserve the prior good repo_count +
|
|
121
|
-
# last_listed_at instead of clobbering with 0/nil.
|
|
122
|
-
org_records, discovered_repos = expand_orgs(config, now, prev_orgs: state.orgs, task: task, semaphore: semaphore)
|
|
123
|
-
|
|
124
|
-
# Phase 2: dedupe explicit + discovered repos by (host, owner,
|
|
125
|
-
# name); explicit wins.
|
|
126
|
-
repos_to_process = dedupe(config.repos, discovered_repos)
|
|
127
|
-
|
|
128
|
-
@reporter.run_started(total: repos_to_process.size)
|
|
129
|
-
|
|
130
|
-
# Phase 3: fan out per-repo work through barrier + semaphore.
|
|
131
|
-
# Results are gathered in a mutex-protected array (barrier
|
|
132
|
-
# tasks run on a Fiber scheduler; shared mutation must be
|
|
133
|
-
# serialized). Each result is a [key, Repo | nil, error] tuple
|
|
134
|
-
# (see process_one for the shape).
|
|
135
|
-
results_mutex = Mutex.new
|
|
136
|
-
results = []
|
|
137
|
-
|
|
138
|
-
repos_to_process.each do |repo_ref|
|
|
139
|
-
barrier.async do
|
|
140
|
-
# `semaphore.async` spawns a child task and returns its
|
|
141
|
-
# Task handle. The barrier only tracks this outer task;
|
|
142
|
-
# if we don't `.wait` on the inner task, `barrier.wait`
|
|
143
|
-
# would return before the per-repo work finishes and
|
|
144
|
-
# `build_new_state` would see an empty results array.
|
|
145
|
-
inner = semaphore.async do
|
|
146
|
-
outcome = process_one(repo_ref, config, now)
|
|
147
|
-
results_mutex.synchronize { results << outcome }
|
|
148
|
-
end
|
|
149
|
-
inner.wait
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
barrier.wait
|
|
153
|
-
|
|
154
|
-
summary = results.each_with_object(Hash.new(0)) do |outcome, h|
|
|
155
|
-
_, repo, error = outcome
|
|
156
|
-
h[error ? "error" : repo.status.to_s] += 1
|
|
157
|
-
end
|
|
158
|
-
@reporter.run_finished(summary)
|
|
159
|
-
|
|
160
|
-
# Phase 4: assemble new state, write once.
|
|
161
|
-
#
|
|
162
|
-
# A failed org listing means `discovered` is missing that
|
|
163
|
-
# org's repos through no fault of the user's config, so
|
|
164
|
-
# pruning would delete every row for a whole org on a
|
|
165
|
-
# transient forge outage. Only prune when the run saw the
|
|
166
|
-
# complete picture.
|
|
167
|
-
listings_complete = org_records.each_value.none?(&:last_error)
|
|
168
|
-
new_state = build_new_state(
|
|
169
|
-
state, results, org_records, prune: prune && listings_complete
|
|
170
|
-
)
|
|
171
|
-
write_result = State::Store.write(paths.state_file, new_state)
|
|
172
|
-
if write_result.failure?
|
|
173
|
-
write_result
|
|
174
|
-
else
|
|
175
|
-
# results.size, not new_state.repos.size — the latter is
|
|
176
|
-
# the accumulated state file and would report every repo
|
|
177
|
-
# ever synced (553 on a scoped one-repo run).
|
|
178
|
-
Dry::Monads::Success(Report.new(state: new_state, processed: results.size))
|
|
179
|
-
end
|
|
180
|
-
ensure
|
|
181
|
-
@reporter.detach
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
if lock_result == State::Lock::NOT_ACQUIRED
|
|
186
|
-
warn "src: skipped — another sync in progress"
|
|
187
|
-
# Nothing ran, so processed is 0 — the state is returned only
|
|
188
|
-
# so callers still get a usable snapshot.
|
|
189
|
-
Dry::Monads::Success(
|
|
190
|
-
Report.new(state: State::Store.load(paths.state_file).success, processed: 0)
|
|
191
|
-
)
|
|
192
|
-
else
|
|
193
|
-
lock_result
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
private
|
|
199
|
-
|
|
200
|
-
# Expands each OrgRef into RepoRefs via the injected forge.
|
|
201
|
-
# Authenticates ONCE before the fan-out (GS2). On auth Failure,
|
|
202
|
-
# records all orgs failed without calling list_org (CF3 preserved).
|
|
203
|
-
# On per-org list_org Failure, the org is recorded with the prior
|
|
204
|
-
# good repo_count + last_listed_at preserved (CF3), last_error set.
|
|
205
|
-
# Fan-out is concurrent via Async::Barrier + Async::Semaphore
|
|
206
|
-
# (GS1: wall-time bounded by slowest org, not sum-of-orgs).
|
|
207
|
-
def expand_orgs(config, now, prev_orgs:, task:, semaphore:)
|
|
208
|
-
org_records = {}
|
|
209
|
-
discovered = []
|
|
210
|
-
org_mutex = Mutex.new
|
|
211
|
-
|
|
212
|
-
@reporter.listing_started(total: config.orgs.size)
|
|
213
|
-
|
|
214
|
-
if config.orgs.empty?
|
|
215
|
-
@reporter.listing_finished
|
|
216
|
-
return [org_records, discovered]
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
auth = @forge.check_authenticated
|
|
220
|
-
if auth.failure?
|
|
221
|
-
# Auth failed once — record all orgs as failed without listing.
|
|
222
|
-
config.orgs.each do |org_ref|
|
|
223
|
-
key = org_key(org_ref)
|
|
224
|
-
prev = prev_orgs[key]
|
|
225
|
-
org_records[key] = State::Store::Org.new(
|
|
226
|
-
last_listed_at: prev&.last_listed_at,
|
|
227
|
-
repo_count: prev&.repo_count || 0,
|
|
228
|
-
last_error: format_org_failure(auth.failure)
|
|
229
|
-
)
|
|
230
|
-
@reporter.org_listed(org_ref, count: nil)
|
|
231
|
-
end
|
|
232
|
-
@reporter.listing_finished
|
|
233
|
-
return [org_records, discovered]
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
# Fan out: one fiber per org, bounded by the same semaphore used
|
|
237
|
-
# for the repo sweep (GS1). A separate org_barrier keeps the
|
|
238
|
-
# listing phase isolated from the repo sweep.
|
|
239
|
-
org_barrier = Async::Barrier.new
|
|
240
|
-
|
|
241
|
-
config.orgs.each do |org_ref|
|
|
242
|
-
org_barrier.async do
|
|
243
|
-
inner = semaphore.async do
|
|
244
|
-
result = @forge.list_org(org_ref)
|
|
245
|
-
key = org_key(org_ref)
|
|
246
|
-
if result.success?
|
|
247
|
-
repos = result.success
|
|
248
|
-
row = State::Store::Org.new(
|
|
249
|
-
last_listed_at: now,
|
|
250
|
-
repo_count: repos.length
|
|
251
|
-
)
|
|
252
|
-
org_mutex.synchronize do
|
|
253
|
-
org_records[key] = row
|
|
254
|
-
discovered.concat(repos)
|
|
255
|
-
end
|
|
256
|
-
@reporter.org_listed(org_ref, count: repos.length)
|
|
257
|
-
else
|
|
258
|
-
prev = prev_orgs[key]
|
|
259
|
-
row = State::Store::Org.new(
|
|
260
|
-
last_listed_at: prev&.last_listed_at,
|
|
261
|
-
repo_count: prev&.repo_count || 0,
|
|
262
|
-
last_error: format_org_failure(result.failure)
|
|
263
|
-
)
|
|
264
|
-
org_mutex.synchronize { org_records[key] = row }
|
|
265
|
-
@reporter.org_listed(org_ref, count: nil)
|
|
266
|
-
end
|
|
267
|
-
rescue => e
|
|
268
|
-
key = org_key(org_ref)
|
|
269
|
-
prev = prev_orgs[key]
|
|
270
|
-
row = State::Store::Org.new(
|
|
271
|
-
last_listed_at: prev&.last_listed_at,
|
|
272
|
-
repo_count: prev&.repo_count || 0,
|
|
273
|
-
last_error: "unhandled: #{e.class}: #{e.message}"
|
|
274
|
-
)
|
|
275
|
-
org_mutex.synchronize { org_records[key] = row }
|
|
276
|
-
@reporter.org_listed(org_ref, count: nil)
|
|
277
|
-
end
|
|
278
|
-
inner.wait
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
org_barrier.wait
|
|
282
|
-
|
|
283
|
-
@reporter.listing_finished
|
|
284
|
-
[org_records, discovered]
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def org_key(o) = "#{o.host}/#{o.name}"
|
|
288
|
-
|
|
289
|
-
def format_org_failure(failure)
|
|
290
|
-
return "list failed" if failure.nil?
|
|
291
|
-
return failure[:reason] if failure.is_a?(Hash) && failure[:reason]
|
|
292
|
-
failure.inspect
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def repo_key(r) = "#{r.host}/#{r.owner}/#{r.name}"
|
|
296
|
-
|
|
297
|
-
# First-write-wins dedupe keyed by (host, owner, name).
|
|
298
|
-
def dedupe(explicit, discovered)
|
|
299
|
-
seen = {}
|
|
300
|
-
explicit.each { |r| seen[repo_key(r)] ||= r }
|
|
301
|
-
discovered.each { |r| seen[repo_key(r)] ||= r }
|
|
302
|
-
seen.values
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# Process a single repo: plan + execute. Returns
|
|
306
|
-
# [key, Repo] on success
|
|
307
|
-
# [key, nil, error_string] on action Failure
|
|
308
|
-
# [key, nil, "unhandled: ..."] on unexpected raise
|
|
309
|
-
# The last-resort rescue is the engine's G8 guarantee: nothing
|
|
310
|
-
# in process_one propagates an exception out of the semaphore.
|
|
311
|
-
def process_one(repo_ref, config, now)
|
|
312
|
-
key = repo_key(repo_ref)
|
|
313
|
-
path = File.join(config.base_dir, repo_ref.host, repo_ref.owner, repo_ref.name)
|
|
314
|
-
|
|
315
|
-
@reporter.repo_started(key)
|
|
316
|
-
|
|
317
|
-
plan_result = RepoPlan.call(
|
|
318
|
-
repo_ref: repo_ref,
|
|
319
|
-
path: path,
|
|
320
|
-
scm: @scm,
|
|
321
|
-
refresh_interval: config.refresh_interval,
|
|
322
|
-
now: now
|
|
323
|
-
)
|
|
324
|
-
if plan_result.failure?
|
|
325
|
-
msg = "plan call failed: #{plan_result.failure.inspect}"
|
|
326
|
-
@reporter.repo_failed(key, msg)
|
|
327
|
-
return [key, nil, msg]
|
|
328
|
-
end
|
|
329
|
-
plan = plan_result.success
|
|
330
|
-
|
|
331
|
-
# The plan's default_branch is implicit in the decision but
|
|
332
|
-
# not exposed in the Plan object. The engine re-probes
|
|
333
|
-
# default_branch for the state record. We MUST NOT call
|
|
334
|
-
# scm.default_branch(path) before the clone — `chdir:` to a
|
|
335
|
-
# non-existent path raises ENOENT in `Kernel#spawn`, which is
|
|
336
|
-
# not a clean Failure (it's an exception, not a non-zero
|
|
337
|
-
# exit). So default_branch is initialized to nil and only
|
|
338
|
-
# populated after the path exists.
|
|
339
|
-
default_branch = nil
|
|
340
|
-
|
|
341
|
-
final_status = plan.status
|
|
342
|
-
last_error = nil
|
|
343
|
-
realized_action = nil
|
|
344
|
-
realized_commits = 0
|
|
345
|
-
|
|
346
|
-
case plan.action
|
|
347
|
-
when :clone
|
|
348
|
-
@reporter.repo_phase(key, :cloning)
|
|
349
|
-
result = @scm.clone(@url_builder.call(repo_ref), path)
|
|
350
|
-
if result.failure?
|
|
351
|
-
final_status = "error"
|
|
352
|
-
last_error = "clone failed: #{result.failure.inspect}"
|
|
353
|
-
realized_action = :error
|
|
354
|
-
else
|
|
355
|
-
# The clone succeeded; the repo is now "clean" (a fresh
|
|
356
|
-
# clone has no local changes). Re-probe default_branch on
|
|
357
|
-
# the now-cloned path.
|
|
358
|
-
final_status = "clean"
|
|
359
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
360
|
-
realized_action = :cloned
|
|
361
|
-
end
|
|
362
|
-
when :fast_forward
|
|
363
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
364
|
-
if default_branch.nil?
|
|
365
|
-
final_status = "error"
|
|
366
|
-
last_error = "default_branch probe failed; cannot fast-forward"
|
|
367
|
-
realized_action = :error
|
|
368
|
-
else
|
|
369
|
-
@reporter.repo_phase(key, :fast_forwarding)
|
|
370
|
-
result = @scm.fast_forward(path, default_branch)
|
|
371
|
-
if result.failure?
|
|
372
|
-
failure = result.failure
|
|
373
|
-
if failure.is_a?(Hash) && failure[:reason].to_s.include?("diverged")
|
|
374
|
-
# G4: divergence is not an error — it's a state.
|
|
375
|
-
final_status = "diverged"
|
|
376
|
-
last_error = failure[:reason].to_s
|
|
377
|
-
realized_action = :diverged
|
|
378
|
-
else
|
|
379
|
-
final_status = "error"
|
|
380
|
-
last_error = failure.inspect
|
|
381
|
-
realized_action = :error
|
|
382
|
-
end
|
|
383
|
-
else
|
|
384
|
-
realized_commits = result.success
|
|
385
|
-
realized_action = (realized_commits > 0) ? :fast_forwarded : :up_to_date
|
|
386
|
-
end
|
|
387
|
-
end
|
|
388
|
-
when :switch
|
|
389
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
390
|
-
if default_branch.nil?
|
|
391
|
-
final_status = "error"
|
|
392
|
-
last_error = "default_branch probe failed; cannot switch"
|
|
393
|
-
realized_action = :error
|
|
394
|
-
else
|
|
395
|
-
# The plan only returns :switch for a clean tree (gate G5;
|
|
396
|
-
# disagreement #1), so this scm.switch is on a clean tree.
|
|
397
|
-
# git switch refuses on dirty by default per its man page;
|
|
398
|
-
# if it ever refused here, that means the plan's guard was
|
|
399
|
-
# bypassed — capture the error.
|
|
400
|
-
@reporter.repo_phase(key, :switching)
|
|
401
|
-
result = @scm.switch(path, default_branch)
|
|
402
|
-
if result.failure?
|
|
403
|
-
final_status = "error"
|
|
404
|
-
last_error = result.failure.inspect
|
|
405
|
-
realized_action = :error
|
|
406
|
-
else
|
|
407
|
-
# The switch succeeded; the repo is now on the default
|
|
408
|
-
# branch with a clean tree.
|
|
409
|
-
final_status = "clean"
|
|
410
|
-
realized_action = :switched
|
|
411
|
-
end
|
|
412
|
-
end
|
|
413
|
-
when :sync_empty
|
|
414
|
-
result = @scm.sync_empty(path)
|
|
415
|
-
if result.failure?
|
|
416
|
-
final_status = "error"
|
|
417
|
-
last_error = "empty-repo sync failed: #{result.failure.inspect}"
|
|
418
|
-
realized_action = :error
|
|
419
|
-
else
|
|
420
|
-
final_status = "clean"
|
|
421
|
-
# empty-repo commit counts are not tracked (commits: 0 always)
|
|
422
|
-
realized_action = :up_to_date
|
|
423
|
-
# nil-safe: still-empty remote → default_branch returns Failure
|
|
424
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
425
|
-
end
|
|
426
|
-
when :skip_fresh, :up_to_date
|
|
427
|
-
# No SCM side effect. State is already "clean". Probe
|
|
428
|
-
# default_branch for the state record (cheap — cached on
|
|
429
|
-
# first call).
|
|
430
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
431
|
-
realized_action = :up_to_date
|
|
432
|
-
when :report_dirty
|
|
433
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
434
|
-
realized_action = :dirty
|
|
435
|
-
when :report_diverged
|
|
436
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
437
|
-
realized_action = :diverged
|
|
438
|
-
when :report_wrong_branch
|
|
439
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
440
|
-
realized_action = :wrong_branch
|
|
441
|
-
when :report_detached
|
|
442
|
-
default_branch = @scm.default_branch(path).value_or { nil }
|
|
443
|
-
realized_action = :detached
|
|
444
|
-
when :report_error
|
|
445
|
-
# The plan classified a probe failure; the diagnostic goes
|
|
446
|
-
# into last_error. Don't re-probe default_branch — the path
|
|
447
|
-
# may not exist or the probe may still fail.
|
|
448
|
-
last_error = plan.reason
|
|
449
|
-
realized_action = :error
|
|
450
|
-
end
|
|
451
|
-
|
|
452
|
-
last_fetch = @scm.last_fetch_at(path).value_or { nil }
|
|
453
|
-
repo = State::Store::Repo.new(
|
|
454
|
-
default_branch: default_branch,
|
|
455
|
-
last_fetch_at: last_fetch,
|
|
456
|
-
last_synced_at: now,
|
|
457
|
-
status: final_status,
|
|
458
|
-
last_error: last_error
|
|
459
|
-
)
|
|
460
|
-
@reporter.repo_finished(key, final_status, action: realized_action, commits: realized_commits)
|
|
461
|
-
[key, repo]
|
|
462
|
-
rescue => e
|
|
463
|
-
# Last-resort: any unexpected exception (e.g. Shell.run raising
|
|
464
|
-
# outside an ambient task) is captured so the engine's barrier
|
|
465
|
-
# completes and state is written.
|
|
466
|
-
msg = "unhandled: #{e.class}: #{e.message}"
|
|
467
|
-
@reporter.repo_failed(key, msg)
|
|
468
|
-
[key, nil, msg]
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
# Assembles the new State from the in-memory prev state + the
|
|
472
|
-
# run's per-repo outcomes + the org records. Failures get a
|
|
473
|
-
# status: error row so every processed repo has a state entry
|
|
474
|
-
# (gate G8).
|
|
475
|
-
#
|
|
476
|
-
# With prune: false (the default) rows for repos this run did not
|
|
477
|
-
# process are carried over untouched — that is what keeps a scoped
|
|
478
|
-
# `sync --repo` from deleting every other repo's state.
|
|
479
|
-
#
|
|
480
|
-
# With prune: true the carry-over is dropped, so the new state
|
|
481
|
-
# holds exactly the repos this run processed and rows for repos
|
|
482
|
-
# removed from config stop accumulating. Starting from an empty
|
|
483
|
-
# hash is equivalent to selecting the processed keys out of prev:
|
|
484
|
-
# every repo in the sweep contributes exactly one result tuple
|
|
485
|
-
# (process_one's rescue guarantees one even on an unhandled
|
|
486
|
-
# raise), so each gets a fresh row in the merge below.
|
|
487
|
-
def build_new_state(prev, results, org_records, prune: false)
|
|
488
|
-
repos = prune ? {} : prev.repos.dup
|
|
489
|
-
results.each do |key, repo, error|
|
|
490
|
-
repos[key] = (repo || State::Store::Repo.new(
|
|
491
|
-
default_branch: nil,
|
|
492
|
-
last_fetch_at: nil,
|
|
493
|
-
last_synced_at: nil,
|
|
494
|
-
status: "error",
|
|
495
|
-
last_error: error
|
|
496
|
-
))
|
|
497
|
-
end
|
|
498
|
-
orgs = prev.orgs.merge(org_records)
|
|
499
|
-
State::Store::State.new(repos: repos, orgs: orgs)
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
|
-
end
|
|
503
|
-
end
|