space-architect 8.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +53 -243
  4. data/lib/space_architect/bug_report.rb +2 -1
  5. data/lib/space_architect/cli/architect.rb +20 -7
  6. data/lib/space_architect/cli.rb +9 -14
  7. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  8. data/lib/space_architect/session_sync/cursor.rb +1 -1
  9. data/lib/space_architect/session_sync/plist.rb +1 -1
  10. data/lib/{space_core → space_architect}/version.rb +2 -2
  11. data/lib/space_architect.rb +1 -1
  12. data/skill/architect/SKILL.md +3 -2
  13. data/skill/architect-vocabulary/SKILL.md +2 -2
  14. metadata +18 -177
  15. data/exe/space +0 -13
  16. data/exe/src +0 -13
  17. data/lib/space_architect/cli/space.rb +0 -29
  18. data/lib/space_architect/cli/src.rb +0 -38
  19. data/lib/space_core/atomic_write.rb +0 -21
  20. data/lib/space_core/cli/base_command.rb +0 -19
  21. data/lib/space_core/cli/build.rb +0 -27
  22. data/lib/space_core/cli/config.rb +0 -49
  23. data/lib/space_core/cli/current.rb +0 -16
  24. data/lib/space_core/cli/help.rb +0 -195
  25. data/lib/space_core/cli/helpers.rb +0 -115
  26. data/lib/space_core/cli/init.rb +0 -29
  27. data/lib/space_core/cli/list.rb +0 -24
  28. data/lib/space_core/cli/loop_status.rb +0 -47
  29. data/lib/space_core/cli/new.rb +0 -38
  30. data/lib/space_core/cli/pack.rb +0 -29
  31. data/lib/space_core/cli/path.rb +0 -16
  32. data/lib/space_core/cli/repeatable_options.rb +0 -84
  33. data/lib/space_core/cli/repo.rb +0 -76
  34. data/lib/space_core/cli/run.rb +0 -44
  35. data/lib/space_core/cli/shell.rb +0 -125
  36. data/lib/space_core/cli/show.rb +0 -21
  37. data/lib/space_core/cli/status.rb +0 -74
  38. data/lib/space_core/cli/use.rb +0 -17
  39. data/lib/space_core/cli.rb +0 -177
  40. data/lib/space_core/commands.rb +0 -51
  41. data/lib/space_core/config.rb +0 -152
  42. data/lib/space_core/errors.rb +0 -14
  43. data/lib/space_core/git_client.rb +0 -49
  44. data/lib/space_core/mise_client.rb +0 -37
  45. data/lib/space_core/oci_builder.rb +0 -56
  46. data/lib/space_core/oci_packer.rb +0 -103
  47. data/lib/space_core/oci_runner.rb +0 -89
  48. data/lib/space_core/paths.rb +0 -58
  49. data/lib/space_core/repo_reference.rb +0 -19
  50. data/lib/space_core/repo_resolver.rb +0 -167
  51. data/lib/space_core/shell_integration.rb +0 -438
  52. data/lib/space_core/slugger.rb +0 -16
  53. data/lib/space_core/space.rb +0 -174
  54. data/lib/space_core/space_store.rb +0 -319
  55. data/lib/space_core/state.rb +0 -86
  56. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  57. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  58. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  59. data/lib/space_core/terminal.rb +0 -161
  60. data/lib/space_core/warnings.rb +0 -13
  61. data/lib/space_core/xdg.rb +0 -33
  62. data/lib/space_core.rb +0 -26
  63. data/lib/space_src/cli/clone.rb +0 -54
  64. data/lib/space_src/cli/config.rb +0 -65
  65. data/lib/space_src/cli/daemon.rb +0 -362
  66. data/lib/space_src/cli/options.rb +0 -21
  67. data/lib/space_src/cli/org.rb +0 -199
  68. data/lib/space_src/cli/repo.rb +0 -169
  69. data/lib/space_src/cli/shell.rb +0 -121
  70. data/lib/space_src/cli/status.rb +0 -75
  71. data/lib/space_src/cli/sync.rb +0 -161
  72. data/lib/space_src/cli.rb +0 -159
  73. data/lib/space_src/cloner.rb +0 -77
  74. data/lib/space_src/config/contract.rb +0 -54
  75. data/lib/space_src/config/duration.rb +0 -79
  76. data/lib/space_src/config/model.rb +0 -49
  77. data/lib/space_src/config/store.rb +0 -156
  78. data/lib/space_src/forge/client.rb +0 -31
  79. data/lib/space_src/forge/github.rb +0 -98
  80. data/lib/space_src/launchd/agent.rb +0 -195
  81. data/lib/space_src/launchd/plist.rb +0 -129
  82. data/lib/space_src/log_rotator.rb +0 -46
  83. data/lib/space_src/migration.rb +0 -43
  84. data/lib/space_src/nav.rb +0 -99
  85. data/lib/space_src/paths.rb +0 -72
  86. data/lib/space_src/scm/client.rb +0 -87
  87. data/lib/space_src/scm/git.rb +0 -232
  88. data/lib/space_src/scm/status.rb +0 -24
  89. data/lib/space_src/shell.rb +0 -90
  90. data/lib/space_src/shell_integration.rb +0 -321
  91. data/lib/space_src/state/lock.rb +0 -59
  92. data/lib/space_src/state/store.rb +0 -140
  93. data/lib/space_src/sync/engine.rb +0 -503
  94. data/lib/space_src/sync/repo_plan.rb +0 -215
  95. data/lib/space_src/sync/report.rb +0 -18
  96. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  97. data/lib/space_src/ui/json_reporter.rb +0 -39
  98. data/lib/space_src/ui/mode.rb +0 -68
  99. data/lib/space_src/ui/plain_reporter.rb +0 -53
  100. data/lib/space_src/ui/reporter.rb +0 -48
  101. data/lib/space_src/version.rb +0 -7
  102. data/lib/space_src.rb +0 -38
@@ -1,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