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