rails-hyperdrive 0.7.0 → 0.9.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.
@@ -3,6 +3,7 @@ require "time"
3
3
  require "rails/hyperdrive/ancestor_locator"
4
4
  require "rails/hyperdrive/bundler_artifact_discovery"
5
5
  require "rails/hyperdrive/claude_md_import"
6
+ require "rails/hyperdrive/config_file"
6
7
  require "rails/hyperdrive/drift_verdict"
7
8
  require "rails/hyperdrive/three_way_merge"
8
9
  require "rails/hyperdrive/eager_footprint"
@@ -16,7 +17,8 @@ module Rails
16
17
  # The application root is explicit and no Rails constant is touched, so any
17
18
  # process that can see the bundle can run this.
18
19
  class InstallPipeline
19
- ARTIFACT_DESTINATIONS = (InstallLayout.dest_roots + [InstallLayout::LOCK_PATH]).freeze
20
+ ARTIFACT_DESTINATIONS =
21
+ (InstallLayout.dest_roots + [InstallLayout::LOCK_PATH, InstallLayout::CONFIG_PATH]).freeze
20
22
 
21
23
  WARN_LINES = 150
22
24
  WARN_TOKENS = 1_500
@@ -26,7 +28,10 @@ module Rails
26
28
  Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, :merged, :sidecars,
27
29
  keyword_init: true)
28
30
 
29
- def initialize(root:, shell:, artifacts:, mode: :preserve, report: BundlerArtifactDiscovery::Report.new)
31
+ Sidecar = Struct.new(:dest, :ancestor, :previous_source, keyword_init: true)
32
+
33
+ def initialize(root:, shell:, artifacts:, mode: :preserve, report: BundlerArtifactDiscovery::Report.new,
34
+ config: nil)
30
35
  raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
31
36
 
32
37
  @root = File.expand_path(root.to_s)
@@ -34,6 +39,7 @@ module Rails
34
39
  @artifacts = artifacts
35
40
  @mode = mode
36
41
  @report = report
42
+ @config = config
37
43
  @bundled_gems = Array(report.bundled_gems).map(&:to_s)
38
44
  @skipped_gems = Array(report.skipped_gems).map(&:to_s)
39
45
  @result = Result.new(
@@ -44,7 +50,8 @@ module Rails
44
50
  def call
45
51
  return halt_schema_ahead if old_lock.schema_ahead?
46
52
 
47
- @new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_settings(old_lock)
53
+ report_settings_warnings
54
+ @new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_document(old_lock)
48
55
  @plan = plan
49
56
 
50
57
  report_collisions
@@ -56,6 +63,7 @@ module Rails
56
63
  carry_orphans
57
64
  eager_guidelines = write_index_md(guidelines)
58
65
  write_claude_md(guidelines)
66
+ clear_resolved_ancestors
59
67
  write_lock
60
68
  print_warnings
61
69
  print_notices
@@ -82,7 +90,19 @@ module Rails
82
90
  end
83
91
 
84
92
  def build_result
85
- @build_result ||= InstallPlan.build(@artifacts, lock: old_lock)
93
+ @build_result ||= InstallPlan.build(@artifacts, config: config)
94
+ end
95
+
96
+ def config
97
+ @config ||= ConfigFile.load(abs(InstallLayout::CONFIG_PATH))
98
+ end
99
+
100
+ def report_settings_warnings
101
+ return if additive?
102
+
103
+ config.warnings.each { |warning| @shell.say_status :warn, warning, :yellow }
104
+ return unless old_lock.legacy_settings?
105
+ @shell.say_status :warn, old_lock.legacy_settings_message(InstallLayout::LOCK_PATH), :yellow
86
106
  end
87
107
 
88
108
  def old_lock
@@ -120,7 +140,7 @@ module Rails
120
140
 
121
141
  def report_disabled
122
142
  build_result.disabled.each do |entry|
123
- @shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::LOCK_PATH})", :blue
143
+ @shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::CONFIG_PATH})", :blue
124
144
  end
125
145
  end
126
146
 
@@ -239,56 +259,101 @@ module Rails
239
259
  # Reconciliation for a locally-modified destination in :sidecar/:merge
240
260
  # mode. The lock records the newest upstream *delivered* — installed
241
261
  # live, merged in, or written as a sidecar — never the live file's own
242
- # hash, so the same upstream version is offered exactly once.
262
+ # hash, so the same upstream version is offered exactly once. While a
263
+ # sidecar is pending the entry also records the *ancestor*: the upstream
264
+ # the live file's own edits descend from, which a later run needs as the
265
+ # merge base.
243
266
  def reconcile_edited(write, old, source_relpath:, final_name:)
244
267
  dest = write[:dest]
245
268
  sidecar = InstallLayout.sidecar_path(dest)
269
+ pending = File.exist?(abs(sidecar))
270
+ pristine = pending && delivered_sidecar?(sidecar, delivered_shas(write, old))
246
271
 
247
272
  if old && old.source_sha == write[:gem_sha]
248
- skip_edited(write, old)
249
- if File.exist?(abs(sidecar))
250
- @shell.say_status :warn, "#{sidecar} (unresolved sidecar; reconcile it with #{dest}, then delete it)", :yellow
251
- end
252
- return
273
+ return retry_pending(write, old, sidecar, pending: pending, pristine: pristine, final_name: final_name)
253
274
  end
254
275
 
255
276
  # An edited sidecar is user work: leave it, and leave the lock at the
256
277
  # old upstream so this delivery is re-offered once the user clears it.
257
- if File.exist?(abs(sidecar)) && !delivered_sidecar?(sidecar, delivered_shas(write, old))
278
+ if pending && !pristine
258
279
  @result.skipped << dest
259
280
  @shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
260
281
  @new_lock.carry(old) if old
261
282
  return
262
283
  end
263
284
 
285
+ deliver(write, old, pending: pending, source_relpath: source_relpath, final_name: final_name)
286
+ end
287
+
288
+ # Nothing new to deliver: the pending sidecar is still the standing
289
+ # offer, so --merge attempts to complete it rather than re-offering it.
290
+ def retry_pending(write, old, sidecar, pending:, pristine:, final_name:)
264
291
  reason = nil
265
- if merge_mode?
266
- if File.exist?(abs(sidecar))
267
- # A pending sidecar means the last delivered upstream never reached
268
- # the live file, so the reconstructable ancestor is stale; merging
269
- # over it would silently revert that delivery.
270
- reason = "previous delivery still unresolved"
271
- else
272
- merged, reason = attempt_merge(write, old, source_relpath: source_relpath, final_name: final_name)
273
- if merged
274
- write_merged(write, merged)
275
- sweep_sidecar(write, old)
276
- return
277
- end
292
+ if merge_mode? && pristine
293
+ ancestor = AncestorLocator.locate_recorded_ancestor(old, final_name: final_name)
294
+ merged, reason = ancestor ? merge_bodies(write, ancestor) : [nil, "ancestor unavailable"]
295
+ if merged
296
+ write_merged(write, merged)
297
+ sweep_sidecar(write, old)
298
+ return
278
299
  end
279
300
  end
280
301
 
281
- write_sidecar(write, reason)
302
+ skip_edited(write, old)
303
+ return unless pending
304
+
305
+ message = "#{sidecar} (unresolved sidecar; reconcile it with #{write[:dest]}, then delete it"
306
+ message += "; #{reason}" if reason
307
+ @shell.say_status :warn, "#{message})", :yellow
282
308
  end
283
309
 
284
- def attempt_merge(write, old, source_relpath:, final_name:)
285
- return [nil, "no previous install recorded"] unless old
310
+ # The ancestor is the live file's own base, so a delivery landing on top
311
+ # of a pending one keeps the ancestor already recorded.
312
+ def deliver(write, old, pending:, source_relpath:, final_name:)
313
+ if pending
314
+ record = recorded_ancestor(old)
315
+ ancestor = AncestorLocator.locate_recorded_ancestor(old, final_name: final_name)
316
+ previous_source = old&.ancestor_label
317
+ reason = "ancestor unavailable" if merge_mode? && ancestor.nil?
318
+ else
319
+ record = fresh_ancestor(old, source_relpath)
320
+ ancestor = locate_ancestor(write, old, source_relpath: source_relpath, final_name: final_name)
321
+ previous_source = old&.source_label
322
+ reason = fresh_merge_reason(old, ancestor) if merge_mode?
323
+ end
286
324
 
287
- ancestor = AncestorLocator.locate(
288
- kind: write[:artifact_kind], relpath: source_relpath, lock_entry: old, final_name: final_name
289
- )
290
- return [nil, "#{old.source_label} not found in installed gems"] unless ancestor
325
+ if merge_mode? && ancestor
326
+ merged, reason = merge_bodies(write, ancestor)
327
+ if merged
328
+ write_merged(write, merged)
329
+ sweep_sidecar(write, old)
330
+ return
331
+ end
332
+ end
333
+
334
+ write_sidecar(write, reason, ancestor: ancestor, previous_source: previous_source, ancestor_record: record)
335
+ end
336
+
337
+ def fresh_merge_reason(old, ancestor)
338
+ return "no previous install recorded" unless old
339
+ return "#{old.source_label} not found in installed gems" unless ancestor
340
+ nil
341
+ end
291
342
 
343
+ # A fresh delivery always reads the ancestor off the entry's own source,
344
+ # never off ancestor keys left over from a window that already closed.
345
+ def fresh_ancestor(old, source_relpath)
346
+ return nil unless old && source_relpath
347
+ { gem: old.source_gem, version: old.source_version, sha: old.source_sha, relpath: source_relpath }
348
+ end
349
+
350
+ def recorded_ancestor(old)
351
+ return nil unless old&.ancestor?
352
+ { gem: old.ancestor_gem, version: old.ancestor_version, sha: old.ancestor_sha,
353
+ relpath: old.ancestor_relpath }
354
+ end
355
+
356
+ def merge_bodies(write, ancestor)
292
357
  ours = live_body(write)
293
358
  if ours.nil? || [ours, ancestor, write[:body]].any? { |b| ThreeWayMerge.binary?(b) }
294
359
  return [nil, "binary content"]
@@ -302,6 +367,16 @@ module Rails
302
367
  end
303
368
  end
304
369
 
370
+ # Reconstruction is sha-gated against the lock entry recorded before this
371
+ # run, so it must happen before the new upstream is locked.
372
+ def locate_ancestor(write, old, source_relpath:, final_name:)
373
+ return nil unless old
374
+
375
+ AncestorLocator.locate(
376
+ kind: write[:artifact_kind], relpath: source_relpath, lock_entry: old, final_name: final_name
377
+ )
378
+ end
379
+
305
380
  def live_body(write)
306
381
  file = abs(write[:dest])
307
382
  return File.binread(file) if write[:type] == :skill_support
@@ -320,14 +395,14 @@ module Rails
320
395
  # The sidecar is byte-identical to what a live install of the new
321
396
  # upstream would write, so `mv <dest>.new <dest>` accepts it wholesale
322
397
  # and the lock already verifies it.
323
- def write_sidecar(write, reason)
398
+ def write_sidecar(write, reason, ancestor: nil, previous_source: nil, ancestor_record: nil)
324
399
  sidecar = InstallLayout.sidecar_path(write[:dest])
325
400
  @shell.create_file sidecar, write[:body]
326
- @result.sidecars << write[:dest]
401
+ @result.sidecars << Sidecar.new(dest: write[:dest], ancestor: ancestor, previous_source: previous_source)
327
402
  message = "#{write[:dest]} (locally modified; new upstream delivered to #{sidecar}"
328
403
  message += "; #{reason}" if reason
329
404
  @shell.say_status :sidecar, "#{message})", :yellow
330
- upsert_lock(write, Time.now.utc)
405
+ upsert_lock(write, Time.now.utc, ancestor: ancestor_record)
331
406
  end
332
407
 
333
408
  # Only a machine-pristine sidecar — one still hashing to a delivered
@@ -387,17 +462,33 @@ module Rails
387
462
  )
388
463
  end
389
464
 
390
- def upsert_lock(write, installed_at)
465
+ def upsert_lock(write, installed_at, ancestor: nil)
391
466
  @new_lock.upsert(
392
467
  path: write[:dest],
393
468
  kind: write[:artifact_kind],
394
469
  source_gem: write[:source_gem],
395
470
  source_version: write[:version],
396
471
  source_sha: write[:gem_sha],
397
- installed_at: installed_at.iso8601
472
+ installed_at: installed_at.iso8601,
473
+ ancestor_gem: ancestor && ancestor[:gem],
474
+ ancestor_version: ancestor && ancestor[:version],
475
+ ancestor_sha: ancestor && ancestor[:sha],
476
+ ancestor_relpath: ancestor && ancestor[:relpath]
398
477
  )
399
478
  end
400
479
 
480
+ # Deleting <dest>.new is the only resolution signal, so a recorded
481
+ # ancestor stays until the run that finds the sidecar gone drops it.
482
+ def clear_resolved_ancestors
483
+ return if additive?
484
+
485
+ @new_lock.each_entry do |entry|
486
+ next unless entry.ancestor?
487
+ next if File.exist?(abs(InstallLayout.sidecar_path(entry.path)))
488
+ @new_lock.clear_ancestor(entry.path)
489
+ end
490
+ end
491
+
401
492
  # The pipeline's only delete path, so it is gated on the file still
402
493
  # matching the content the lock recorded: hand-edited work is reported and
403
494
  # left for its owner to remove. The dest's sidecar goes with it, since
@@ -426,7 +517,7 @@ module Rails
426
517
  type = InstallLayout::ARTIFACT_TYPES[entry.kind]
427
518
  next unless type
428
519
  next if @new_lock.entry(entry.path)
429
- next unless InstallPlan.disabled_dest?(old_lock, type, entry.path, source_gem: entry.source_gem)
520
+ next unless InstallPlan.disabled_dest?(config, type, entry.path, source_gem: entry.source_gem)
430
521
 
431
522
  remove_or_carry(entry) { "disabled but locally modified; delete it by hand" }
432
523
  end
@@ -53,7 +53,7 @@ module Rails
53
53
  # disables every variant and a postfixed name disables just one. The
54
54
  # postfixed form is honored whether or not a collision exists today, so
55
55
  # the opt-out survives a collision appearing or resolving.
56
- def build(artifacts, lock: nil)
56
+ def build(artifacts, config: nil)
57
57
  result = Result.new(entries: [], disabled: [])
58
58
  artifacts.group_by { |a| [a.artifact_type, a.name] }.each do |(type, name), group|
59
59
  collision = group.size > 1
@@ -66,8 +66,8 @@ module Rails
66
66
  dest: InstallLayout.dest_for(type, final_name),
67
67
  collision: collision
68
68
  )
69
- dropped = lock && (lock.disabled?(type, name) ||
70
- lock.disabled?(type, InstallLayout.postfixed_name(name, artifact.source_gem)))
69
+ dropped = config && (config.disabled?(type, name) ||
70
+ config.disabled?(type, InstallLayout.postfixed_name(name, artifact.source_gem)))
71
71
  (dropped ? result.disabled : result.entries) << entry
72
72
  end
73
73
  end
@@ -77,13 +77,13 @@ module Rails
77
77
  # A supporting file is disabled through its owning skill's name. Given the
78
78
  # installing gem, a canonically-installed file also answers to the
79
79
  # postfixed name that disabled it while it was one of several variants.
80
- def disabled_dest?(lock, type, dest, source_gem: nil)
80
+ def disabled_dest?(config, type, dest, source_gem: nil)
81
81
  name = InstallLayout.installed_name(type, dest)
82
82
  return false unless name
83
83
  lookup = type == :skill_support ? :skill : type
84
84
  base = InstallLayout.base_name(name)
85
- return true if lock.disabled?(lookup, name) || lock.disabled?(lookup, base)
86
- !source_gem.nil? && lock.disabled?(lookup, InstallLayout.postfixed_name(base, source_gem))
85
+ return true if config.disabled?(lookup, name) || config.disabled?(lookup, base)
86
+ !source_gem.nil? && config.disabled?(lookup, InstallLayout.postfixed_name(base, source_gem))
87
87
  end
88
88
  end
89
89
  end
@@ -1,22 +1,36 @@
1
1
  require "yaml"
2
+ require "rails/hyperdrive/install_layout"
2
3
 
3
4
  module Rails
4
5
  module Hyperdrive
5
6
  # installed_at is volatile metadata, never an input to any comparison.
6
7
  class LockFile
7
- SCHEMA_VERSION = 2
8
+ SCHEMA_VERSION = 3
8
9
  STATE_PRESENT = "present".freeze
9
10
  STATE_REMOVED = "removed-by-user".freeze
10
11
 
11
- DISABLED_KEYS = { skill: "skills", guideline: "guidelines", agent: "agents", command: "commands" }.freeze
12
-
13
12
  # In-memory form of one files: entry. On disk, source_gem and
14
13
  # source_version are a single "gem@version" string; the split/join lives
15
14
  # in this file only.
16
- Entry = Struct.new(:path, :kind, :source_gem, :source_version, :source_sha, :installed_at, keyword_init: true) do
15
+ Entry = Struct.new(
16
+ :path, :kind, :source_gem, :source_version, :source_sha, :installed_at,
17
+ :ancestor_gem, :ancestor_version, :ancestor_sha, :ancestor_relpath,
18
+ keyword_init: true
19
+ ) do
17
20
  def source_label
18
21
  source_version ? "#{source_gem}@#{source_version}" : source_gem
19
22
  end
23
+
24
+ # The upstream the live file's edits descend from, recorded only while
25
+ # a sidecar delivery is pending.
26
+ def ancestor_label
27
+ return nil unless ancestor_gem
28
+ ancestor_version ? "#{ancestor_gem}@#{ancestor_version}" : ancestor_gem
29
+ end
30
+
31
+ def ancestor?
32
+ !ancestor_gem.nil? && !ancestor_sha.nil?
33
+ end
20
34
  end
21
35
 
22
36
  attr_reader :path, :schema_version
@@ -39,8 +53,7 @@ module Rails
39
53
  @claude_md_state = nil # nil = no lock has been written yet
40
54
  @files = {} # path(String) => Entry
41
55
  @document = {} # raw parsed YAML, kept so unknown keys survive
42
- @disabled = empty_disabled
43
- @enabled = []
56
+ @legacy_settings = false
44
57
  @schema_version = nil
45
58
  end
46
59
 
@@ -54,8 +67,7 @@ module Rails
54
67
  @schema_version = data["version"]
55
68
  claude_md = data["claude_md"]
56
69
  @claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
57
- @disabled = parse_disabled(data["disabled"])
58
- @enabled = parse_enabled(data["enabled"])
70
+ @legacy_settings = data.key?("disabled") || data.key?("enabled")
59
71
  Array(data["files"]).each do |raw|
60
72
  next unless raw.is_a?(Hash)
61
73
  entry = build_entry(raw)
@@ -79,6 +91,17 @@ module Rails
79
91
  "this installer supports #{SCHEMA_VERSION}); upgrade rails-hyperdrive"
80
92
  end
81
93
 
94
+ def legacy_settings?
95
+ @legacy_settings
96
+ end
97
+
98
+ def legacy_settings_message(display_path)
99
+ "#{display_path} carries disabled:/enabled:; those settings now live in " \
100
+ "#{InstallLayout::CONFIG_PATH} and are ignored here. Move them now: disabled " \
101
+ "artifacts install again, and artifacts from gems enabled only there are " \
102
+ "removed by the next init/sync"
103
+ end
104
+
82
105
  def entry(file_path)
83
106
  @files[file_path.to_s]
84
107
  end
@@ -91,35 +114,43 @@ module Rails
91
114
  @files.values.each(&block)
92
115
  end
93
116
 
94
- def disabled?(type, name)
95
- Array(@disabled[type.to_sym]).include?(name.to_s)
96
- end
97
-
98
- # Hand-editable list of gems the app opts into as companions.
99
- def enabled_gems
100
- @enabled
101
- end
102
-
103
117
  # Adopt the state that is not derived from installed content, so
104
118
  # rewriting the file preserves it.
105
- def carry_settings(other)
119
+ def carry_document(other)
106
120
  @document = other.document.dup
107
- @disabled = other.disabled_lists.dup
108
- @enabled = other.enabled_gems.dup
109
121
  self
110
122
  end
111
123
 
112
- def upsert(path:, kind:, source_gem:, source_version:, source_sha:, installed_at:)
124
+ def upsert(path:, kind:, source_gem:, source_version:, source_sha:, installed_at:,
125
+ ancestor_gem: nil, ancestor_version: nil, ancestor_sha: nil, ancestor_relpath: nil)
113
126
  @files[path.to_s] = Entry.new(
114
127
  path: path.to_s,
115
128
  kind: kind.to_s,
116
129
  source_gem: source_gem.to_s,
117
130
  source_version: source_version.to_s,
118
131
  source_sha: source_sha.to_s,
119
- installed_at: installed_at.to_s
132
+ installed_at: installed_at.to_s,
133
+ ancestor_gem: ancestor_gem,
134
+ ancestor_version: ancestor_version,
135
+ ancestor_sha: ancestor_sha,
136
+ ancestor_relpath: ancestor_relpath
120
137
  )
121
138
  end
122
139
 
140
+ # Replaces the entry rather than mutating it, so an entry carried from a
141
+ # lock read earlier in the run keeps the values that run compared against.
142
+ def clear_ancestor(file_path)
143
+ entry = @files[file_path.to_s]
144
+ return unless entry&.ancestor_gem || entry&.ancestor_sha || entry&.ancestor_relpath
145
+
146
+ @files[entry.path] = entry.dup.tap do |cleared|
147
+ cleared.ancestor_gem = nil
148
+ cleared.ancestor_version = nil
149
+ cleared.ancestor_sha = nil
150
+ cleared.ancestor_relpath = nil
151
+ end
152
+ end
153
+
123
154
  def carry(entry)
124
155
  return unless entry && entry.path
125
156
  @files[entry.path] = entry
@@ -132,14 +163,16 @@ module Rails
132
163
  document = @document.merge(
133
164
  "version" => SCHEMA_VERSION,
134
165
  "claude_md" => carried.merge("state" => @claude_md_state),
135
- "disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
136
- "enabled" => @enabled,
137
166
  "files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
138
167
  )
139
168
  # A nil state means no import line is being managed. Recording one anyway
140
169
  # would make the next run read the absent line as a deletion the user
141
170
  # made, and never add it back.
142
171
  document.delete("claude_md") if @claude_md_state.nil?
172
+ # Every other unknown key round-trips; these two must not, or a lock
173
+ # would keep asserting settings nothing reads.
174
+ document.delete("disabled")
175
+ document.delete("enabled")
143
176
  document.to_yaml
144
177
  end
145
178
 
@@ -149,39 +182,22 @@ module Rails
149
182
  @document
150
183
  end
151
184
 
152
- def disabled_lists
153
- @disabled
154
- end
155
-
156
185
  private
157
186
 
158
- def empty_disabled
159
- DISABLED_KEYS.keys.each_with_object({}) { |type, h| h[type] = [] }
160
- end
161
-
162
- def parse_disabled(raw)
163
- return empty_disabled unless raw.is_a?(Hash)
164
-
165
- DISABLED_KEYS.each_with_object({}) do |(type, key), h|
166
- h[type] = Array(raw[key]).map { |name| name.to_s.strip }.reject(&:empty?).uniq
167
- end
168
- end
169
-
170
- def parse_enabled(raw)
171
- return [] unless raw.is_a?(Array)
172
-
173
- raw.map { |name| name.to_s.strip }.reject(&:empty?).uniq
174
- end
175
-
176
187
  def build_entry(raw)
177
188
  source_gem, source_version = split_source(raw["source"])
189
+ ancestor_gem, ancestor_version = split_source(raw["ancestor_source"])
178
190
  Entry.new(
179
191
  path: raw["path"],
180
192
  kind: raw["artifact"],
181
193
  source_gem: source_gem,
182
194
  source_version: source_version,
183
195
  source_sha: raw["source_sha"],
184
- installed_at: raw["installed_at"]
196
+ installed_at: raw["installed_at"],
197
+ ancestor_gem: ancestor_gem,
198
+ ancestor_version: ancestor_version,
199
+ ancestor_sha: raw["ancestor_sha"],
200
+ ancestor_relpath: raw["ancestor_relpath"]
185
201
  )
186
202
  end
187
203
 
@@ -195,13 +211,19 @@ module Rails
195
211
  end
196
212
 
197
213
  def serialize_entry(entry)
198
- {
214
+ raw = {
199
215
  "path" => entry.path,
200
216
  "artifact" => entry.kind,
201
217
  "source" => entry.source_label,
202
- "source_sha" => entry.source_sha,
203
- "installed_at" => entry.installed_at
218
+ "source_sha" => entry.source_sha
204
219
  }
220
+ # installed_at is re-added after the optional keys so their absence
221
+ # leaves an entry's key order untouched.
222
+ raw["ancestor_source"] = entry.ancestor_label if entry.ancestor_label
223
+ raw["ancestor_sha"] = entry.ancestor_sha if entry.ancestor_sha
224
+ raw["ancestor_relpath"] = entry.ancestor_relpath if entry.ancestor_relpath
225
+ raw["installed_at"] = entry.installed_at
226
+ raw
205
227
  end
206
228
  end
207
229
  end
@@ -102,9 +102,14 @@ module Rails
102
102
  entries.each do |key, entry|
103
103
  next if Array(kind.prefix_key).include?(key)
104
104
 
105
+ face = BundlerArtifactDiscovery.target_path(key)
105
106
  unless shipped.include?(key)
106
107
  problems << "#{kind.section} entry '#{key}' names no shipped #{kind.shipped_label}"
107
108
  end
109
+ if face != key && entries.key?(face)
110
+ problems << "#{kind.section} entry '#{key}': '#{face}' keys the same file; keep one spelling"
111
+ end
112
+
108
113
  unless entry.is_a?(Hash)
109
114
  problems << "#{kind.section} entry '#{key}' must be a map of gating keys"
110
115
  next
@@ -218,8 +223,9 @@ module Rails
218
223
  end
219
224
  end
220
225
 
226
+ # Both spellings of a template-shipped file, since either gates it.
221
227
  def shipped_flat(repo_spec, kind, manifest)
222
- BundlerArtifactDiscovery.flat_paths(repo_spec, kind, manifest: manifest).map { |p| File.basename(p) }
228
+ BundlerArtifactDiscovery.flat_keys(repo_spec, kind, manifest: manifest)
223
229
  end
224
230
 
225
231
  private_class_method :missing_manifest, :problems_in, :parse_root, :each_entry, :check_section_settings,
@@ -0,0 +1,47 @@
1
+ You are resolving one file for the rails-hyperdrive installer.
2
+
3
+ The file is a rails-hyperdrive-managed file of kind `<%= kind %>`; the new
4
+ upstream version is shipped by <%= source %>.<% if previous_source && !previous_source.empty? %> The version the project
5
+ last received came from <%= previous_source %>.<% end %> The project has edited its copy
6
+ locally, so the local edits and the new upstream version have to be reconciled
7
+ by hand.
8
+
9
+ Inputs:
10
+
11
+ - LOCAL: <%= local %>
12
+ The project's current copy, including its local edits.
13
+ - REMOTE: <%= remote %>
14
+ The new upstream copy, exactly as <%= source %> ships it. It contains none of
15
+ the local edits.
16
+ <% if base -%>
17
+ - BASE: <%= base %>
18
+ The common ancestor: the upstream copy the local edits were made on top of.
19
+ Anything that differs between BASE and LOCAL is a local edit; anything that
20
+ differs between BASE and REMOTE is an upstream change.
21
+ <% else -%>
22
+ - BASE: not available. There is no common ancestor to compare against, so
23
+ decide which differences are local edits by reading LOCAL and REMOTE.
24
+ <% end -%>
25
+
26
+ Write the reconciled file to:
27
+
28
+ - MERGED: <%= merged %>
29
+ This is the same path as LOCAL, so you are editing the project's copy in
30
+ place.
31
+
32
+ Rules:
33
+
34
+ - Take every upstream change from REMOTE.
35
+ - Keep every local customisation from LOCAL.
36
+ - Where an upstream change and a local edit touch the same thing, keep the
37
+ local intent and express it in terms of the new upstream text.
38
+ - Write a complete, valid file. No conflict markers, no commentary about the
39
+ merge, no placeholders, and nothing invented that appears in neither input.
40
+ <% if kind == "skill" || kind == "agent" -%>
41
+ - Keep the YAML frontmatter valid, and leave the `name:` line exactly as REMOTE
42
+ has it.
43
+ <% end -%>
44
+ - If you are not confident the result is correct, change nothing and exit with a
45
+ non-zero status.
46
+
47
+ Exit 0 only when MERGED holds the reconciled file.
@@ -0,0 +1,42 @@
1
+ require "erb"
2
+
3
+ module Rails
4
+ module Hyperdrive
5
+ # Renders the text bound to $PROMPT for the resolver command. The binding
6
+ # is an ordinary object rather than a sandbox: a user-supplied template
7
+ # runs arbitrary Ruby with the privileges of whoever ran the sync.
8
+ module ResolvePrompt
9
+ DEFAULT_PATH = File.expand_path("resolve/prompt.md.erb", __dir__).freeze
10
+
11
+ KNOBS = %i[local remote base merged source previous_source kind].freeze
12
+
13
+ class Context
14
+ attr_reader(*KNOBS)
15
+
16
+ def initialize(local:, remote:, base:, merged:, source:, previous_source:, kind:)
17
+ @local = local
18
+ @remote = remote
19
+ @base = base
20
+ @merged = merged
21
+ @source = source
22
+ @previous_source = previous_source
23
+ @kind = kind
24
+ end
25
+
26
+ def template_binding
27
+ binding
28
+ end
29
+ end
30
+
31
+ module_function
32
+
33
+ def render(template, **knobs)
34
+ ERB.new(template, trim_mode: "-").result(Context.new(**knobs).template_binding)
35
+ end
36
+
37
+ def default_template
38
+ File.read(DEFAULT_PATH)
39
+ end
40
+ end
41
+ end
42
+ end