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.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +284 -0
  4. data/exe/architect +13 -0
  5. data/exe/space +13 -0
  6. data/lib/space_architect/architect_mission.rb +436 -0
  7. data/lib/space_architect/atomic_write.rb +21 -0
  8. data/lib/space_architect/cli/architect.rb +388 -0
  9. data/lib/space_architect/cli/config.rb +61 -0
  10. data/lib/space_architect/cli/current.rb +22 -0
  11. data/lib/space_architect/cli/helpers.rb +117 -0
  12. data/lib/space_architect/cli/init.rb +35 -0
  13. data/lib/space_architect/cli/list.rb +30 -0
  14. data/lib/space_architect/cli/new.rb +43 -0
  15. data/lib/space_architect/cli/options.rb +12 -0
  16. data/lib/space_architect/cli/path.rb +22 -0
  17. data/lib/space_architect/cli/repo.rb +88 -0
  18. data/lib/space_architect/cli/shell.rb +137 -0
  19. data/lib/space_architect/cli/show.rb +27 -0
  20. data/lib/space_architect/cli/space.rb +35 -0
  21. data/lib/space_architect/cli/src.rb +32 -0
  22. data/lib/space_architect/cli/status.rb +39 -0
  23. data/lib/space_architect/cli/use.rb +23 -0
  24. data/lib/space_architect/cli.rb +102 -0
  25. data/lib/space_architect/config.rb +152 -0
  26. data/lib/space_architect/dispatcher.rb +21 -0
  27. data/lib/space_architect/errors.rb +14 -0
  28. data/lib/space_architect/git_client.rb +49 -0
  29. data/lib/space_architect/harness.rb +168 -0
  30. data/lib/space_architect/mise_client.rb +37 -0
  31. data/lib/space_architect/repo_reference.rb +19 -0
  32. data/lib/space_architect/repo_resolver.rb +167 -0
  33. data/lib/space_architect/shell_integration.rb +438 -0
  34. data/lib/space_architect/slugger.rb +16 -0
  35. data/lib/space_architect/space.rb +110 -0
  36. data/lib/space_architect/space_store.rb +319 -0
  37. data/lib/space_architect/state.rb +86 -0
  38. data/lib/space_architect/templates/architect.md.erb +48 -0
  39. data/lib/space_architect/templates/iteration.md.erb +66 -0
  40. data/lib/space_architect/terminal.rb +163 -0
  41. data/lib/space_architect/version.rb +5 -0
  42. data/lib/space_architect/warnings.rb +13 -0
  43. data/lib/space_architect/xdg.rb +33 -0
  44. data/lib/space_architect.rb +26 -0
  45. data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
  46. data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
  47. data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
  48. data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
  49. data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
  50. data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
  51. data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
  52. data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
  53. data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
  54. data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
  55. data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
  56. data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
  57. data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
  58. data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
  59. data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
  60. data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
  61. data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
  62. data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
  63. data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
  64. data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
  65. data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
  66. data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
  67. data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
  68. data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
  69. data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
  70. data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
  71. data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
  72. data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
  73. data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
  74. data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
  75. data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
  76. data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
  77. data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
  78. data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
  79. data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
  80. 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