rails-hyperdrive 0.8.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.
@@ -2,6 +2,7 @@ require "bundler"
2
2
  require "rails/hyperdrive/bundler_artifact_discovery"
3
3
  require "rails/hyperdrive/drift_verdict"
4
4
  require "rails/hyperdrive/install_layout"
5
+ require "rails/hyperdrive/lock_file"
5
6
  require "rails/hyperdrive/skill_template"
6
7
 
7
8
  module Rails
@@ -34,6 +35,40 @@ module Rails
34
35
  nil
35
36
  end
36
37
 
38
+ # The sha gate runs against the ancestor an entry records while its
39
+ # sidecar is pending, not against the entry's own source, which by then
40
+ # names the pending delivery.
41
+ def locate_recorded_ancestor(lock_entry, final_name: nil, gem_paths: Gem.path, resolved: nil)
42
+ return nil unless lock_entry&.ancestor?
43
+
44
+ locate(
45
+ kind: lock_entry.kind,
46
+ relpath: lock_entry.ancestor_relpath,
47
+ lock_entry: LockFile::Entry.new(
48
+ source_gem: lock_entry.ancestor_gem,
49
+ source_version: lock_entry.ancestor_version,
50
+ source_sha: lock_entry.ancestor_sha
51
+ ),
52
+ final_name: final_name || final_name_from_dest(lock_entry),
53
+ gem_paths: gem_paths,
54
+ resolved: resolved
55
+ )
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ # Only the kinds whose frontmatter carries the name need one, and for
61
+ # those the dest was built from it.
62
+ def final_name_from_dest(lock_entry)
63
+ type = InstallLayout::ARTIFACT_TYPES[lock_entry.kind.to_s]
64
+ descriptor = type && InstallLayout.kind(type)
65
+ return nil unless descriptor&.collision_rewrites_name
66
+
67
+ descriptor.installed_name(lock_entry.path.to_s)
68
+ rescue StandardError
69
+ nil
70
+ end
71
+
37
72
  def read_candidate(gem_root, relpath, kind:, resolved:)
38
73
  exact = File.join(gem_root, relpath)
39
74
  if File.file?(exact)
@@ -67,7 +102,7 @@ module Rails
67
102
  nil
68
103
  end
69
104
 
70
- private_class_method :read_candidate, :install_ready, :resolved_bundle
105
+ private_class_method :final_name_from_dest, :read_candidate, :install_ready, :resolved_bundle
71
106
  end
72
107
  end
73
108
  end
@@ -1,3 +1,4 @@
1
+ require "rails/hyperdrive/config_file"
1
2
  require "rails/hyperdrive/drift_verdict"
2
3
  require "rails/hyperdrive/install_layout"
3
4
  require "rails/hyperdrive/install_plan"
@@ -28,16 +29,17 @@ module Rails
28
29
  end
29
30
  end
30
31
 
31
- def self.compare(root:, artifacts:, bundled_gems: [])
32
- new(root: root, artifacts: artifacts, bundled_gems: bundled_gems).tap(&:compare)
32
+ def self.compare(root:, artifacts:, bundled_gems: [], config: nil)
33
+ new(root: root, artifacts: artifacts, bundled_gems: bundled_gems, config: config).tap(&:compare)
33
34
  end
34
35
 
35
36
  attr_reader :entries
36
37
 
37
- def initialize(root:, artifacts:, bundled_gems: [])
38
+ def initialize(root:, artifacts:, bundled_gems: [], config: nil)
38
39
  @root = File.expand_path(root.to_s)
39
40
  @artifacts = artifacts
40
41
  @bundled_gems = Array(bundled_gems).map(&:to_s)
42
+ @config = config
41
43
  @entries = []
42
44
  end
43
45
 
@@ -45,7 +47,7 @@ module Rails
45
47
  lock = LockFile.load(File.join(@root, InstallLayout::LOCK_PATH))
46
48
  offered = {}
47
49
 
48
- InstallPlan.build(@artifacts, lock: lock).entries.each do |plan_entry|
50
+ InstallPlan.build(@artifacts, config: config).entries.each do |plan_entry|
49
51
  offered[plan_entry.dest] = [DriftVerdict.body_sha(plan_entry.install_ready_body), plan_entry.source_label, plan_entry.type]
50
52
  plan_entry.support_files.each do |file|
51
53
  offered[file[:dest]] = [DriftVerdict.body_sha(file[:body]), plan_entry.source_label, :skill_support]
@@ -71,7 +73,7 @@ module Rails
71
73
  # A disabled artifact left on disk was reported at install time; the
72
74
  # bundle still ships it, so it is not an orphan.
73
75
  type = InstallLayout::ARTIFACT_TYPES[locked.kind]
74
- next if type && InstallPlan.disabled_dest?(lock, type, locked.path, source_gem: locked.source_gem)
76
+ next if type && InstallPlan.disabled_dest?(config, type, locked.path, source_gem: locked.source_gem)
75
77
 
76
78
  @entries << Entry.new(
77
79
  path: locked.path, state: :orphaned, artifact: locked.kind&.to_sym,
@@ -90,6 +92,12 @@ module Rails
90
92
  def stale?
91
93
  !missing.empty? || !outdated.empty? || !orphaned.empty?
92
94
  end
95
+
96
+ private
97
+
98
+ def config
99
+ @config ||= ConfigFile.load(File.join(@root, InstallLayout::CONFIG_PATH))
100
+ end
93
101
  end
94
102
  end
95
103
  end
@@ -1,6 +1,7 @@
1
1
  require "bundler"
2
2
  require "rails/hyperdrive/artifact_status"
3
3
  require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/config_file"
4
5
  require "rails/hyperdrive/install_layout"
5
6
  require "rails/hyperdrive/install_pipeline"
6
7
  require "rails/hyperdrive/install_shell"
@@ -14,7 +15,7 @@ module Rails
14
15
  DEVELOPMENT = "development".freeze
15
16
 
16
17
  Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :advisories, :fence_warnings,
17
- :halted, :skipped, :error, keyword_init: true) do
18
+ :config_warnings, :halted, :skipped, :error, keyword_init: true) do
18
19
  def ran?
19
20
  skipped.nil?
20
21
  end
@@ -27,7 +28,7 @@ module Rails
27
28
  return [] unless ran?
28
29
  return [halted] if halted
29
30
 
30
- lines = []
31
+ lines = Array(config_warnings).dup
31
32
  unless Array(installed).empty?
32
33
  lines << "installed #{installed.size} artifact(s):"
33
34
  installed.each { |path| lines << " #{path}" }
@@ -57,9 +58,14 @@ module Rails
57
58
  lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
58
59
  return halted(lock.schema_ahead_message(InstallLayout::LOCK_PATH)) if lock.schema_ahead?
59
60
 
61
+ config = ConfigFile.load(File.join(root, InstallLayout::CONFIG_PATH))
62
+ config_warnings = config.warnings.dup
63
+ config_warnings << lock.legacy_settings_message(InstallLayout::LOCK_PATH) if lock.legacy_settings?
64
+
60
65
  report = BundlerArtifactDiscovery::Report.new
61
- artifacts = BundlerArtifactDiscovery.discover(enabled_gems: lock.enabled_gems, report: report)
62
- status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems)
66
+ artifacts = BundlerArtifactDiscovery.discover(enabled_gems: config.enabled_gems, report: report)
67
+ status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems,
68
+ config: config)
63
69
 
64
70
  installed = []
65
71
  unwired = false
@@ -70,7 +76,8 @@ module Rails
70
76
  shell: InstallShell.new(root: root, io: io),
71
77
  artifacts: artifacts,
72
78
  mode: :additive,
73
- report: report
79
+ report: report,
80
+ config: config
74
81
  )
75
82
  installed = pipeline.call.installed
76
83
  # This runs from a bundle install, which must not edit CLAUDE.md, so a
@@ -80,7 +87,8 @@ module Rails
80
87
  end
81
88
 
82
89
  Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned,
83
- unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings)
90
+ unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings,
91
+ config_warnings: config_warnings)
84
92
  rescue StandardError => e
85
93
  skip(:error, e)
86
94
  end
@@ -106,12 +114,13 @@ module Rails
106
114
 
107
115
  def skip(reason, error = nil)
108
116
  Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
109
- skipped: reason, error: error)
117
+ config_warnings: [], skipped: reason, error: error)
110
118
  end
111
119
 
112
120
  # A completed run that installed nothing and has a reason to print.
113
121
  def halted(reason)
114
- Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [], halted: reason)
122
+ Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
123
+ config_warnings: [], halted: reason)
115
124
  end
116
125
  end
117
126
  end
@@ -308,7 +308,7 @@ module Rails
308
308
 
309
309
  count = found.map { |p| File.dirname(p) }.uniq.size
310
310
  report.notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
311
- "in .hyperdrive/lock.yml and re-run bin/rails hyperdrive:sync to install them"
311
+ "in #{InstallLayout::CONFIG_PATH} and re-run bin/rails hyperdrive:sync to install them"
312
312
  end
313
313
 
314
314
  # Frontmatter's schema is exactly name and description; any other key is
@@ -0,0 +1,135 @@
1
+ require "yaml"
2
+ require "rails/hyperdrive/install_layout"
3
+
4
+ module Rails
5
+ module Hyperdrive
6
+ # The hand-owned settings file: which artifacts never to install, and which
7
+ # gems to treat as companions. Fail-open — a malformed part warns and reads
8
+ # as absent, and nothing raises.
9
+ class ConfigFile
10
+ DISABLED_KEYS = { skill: "skills", guideline: "guidelines", agent: "agents", command: "commands" }.freeze
11
+
12
+ attr_reader :path, :warnings, :resolve_command, :resolve_prompt
13
+
14
+ def self.load(path)
15
+ new(path).tap(&:read)
16
+ end
17
+
18
+ def initialize(path)
19
+ @path = path.to_s
20
+ @disabled = empty_disabled
21
+ @enabled = []
22
+ @warnings = []
23
+ end
24
+
25
+ def exist?
26
+ File.file?(@path)
27
+ end
28
+
29
+ def read
30
+ return self unless File.file?(@path)
31
+
32
+ data = YAML.safe_load(File.read(@path))
33
+ return self if data.nil?
34
+ return warn_and_empty("root is not a map; reading it as empty") unless data.is_a?(Hash)
35
+
36
+ read_disabled(data["disabled"])
37
+ read_enabled(data["enabled"])
38
+ read_resolve(data["resolve"])
39
+ self
40
+ rescue StandardError => e
41
+ warn_and_empty("could not be parsed (#{first_line(e.message)}); reading it as empty")
42
+ end
43
+
44
+ def disabled?(type, name)
45
+ Array(@disabled[type.to_sym]).include?(name.to_s)
46
+ end
47
+
48
+ def enabled_gems
49
+ @enabled
50
+ end
51
+
52
+ private
53
+
54
+ def read_disabled(raw)
55
+ return if raw.nil?
56
+ return report("disabled: must be a map of kind to list; ignoring it") unless raw.is_a?(Hash)
57
+
58
+ DISABLED_KEYS.each do |type, key|
59
+ value = raw[key]
60
+ next if value.nil?
61
+ unless value.is_a?(Array)
62
+ report("disabled: #{key}: must be a list of names; ignoring it")
63
+ next
64
+ end
65
+ @disabled[type] = normalize(value)
66
+ end
67
+ end
68
+
69
+ def read_resolve(raw)
70
+ return if raw.nil?
71
+ return report("resolve: must be a map; ignoring it") unless raw.is_a?(Hash)
72
+
73
+ @resolve_command = read_resolve_command(raw["command"])
74
+ @resolve_prompt = read_resolve_prompt(raw["prompt"])
75
+ end
76
+
77
+ def read_resolve_command(value)
78
+ return nil if value.nil?
79
+ return value.strip if value.is_a?(String) && !value.strip.empty?
80
+
81
+ report("resolve: command: must be a non-empty string; ignoring it")
82
+ nil
83
+ end
84
+
85
+ # The prompt is read relative to the app root; a path that escapes it is
86
+ # refused.
87
+ def read_resolve_prompt(value)
88
+ return nil if value.nil?
89
+ unless value.is_a?(String) && !value.strip.empty?
90
+ report("resolve: prompt: must be a non-empty string; ignoring it")
91
+ return nil
92
+ end
93
+
94
+ prompt = value.strip
95
+ if prompt.split("/").include?("..") || prompt.start_with?("/")
96
+ report("resolve: prompt: must be a path inside the app; ignoring it")
97
+ return nil
98
+ end
99
+ prompt
100
+ end
101
+
102
+ def read_enabled(raw)
103
+ return if raw.nil?
104
+ return report("enabled: must be a list of gem names; ignoring it") unless raw.is_a?(Array)
105
+
106
+ @enabled = normalize(raw)
107
+ end
108
+
109
+ def normalize(list)
110
+ list.map { |name| name.to_s.strip }.reject(&:empty?).uniq
111
+ end
112
+
113
+ def warn_and_empty(message)
114
+ report(message)
115
+ @disabled = empty_disabled
116
+ @enabled = []
117
+ @resolve_command = nil
118
+ @resolve_prompt = nil
119
+ self
120
+ end
121
+
122
+ def report(message)
123
+ @warnings << "#{InstallLayout::CONFIG_PATH} #{message}"
124
+ end
125
+
126
+ def first_line(message)
127
+ message.to_s.lines.first.to_s.strip
128
+ end
129
+
130
+ def empty_disabled
131
+ DISABLED_KEYS.keys.each_with_object({}) { |type, h| h[type] = [] }
132
+ end
133
+ end
134
+ end
135
+ end
@@ -11,6 +11,7 @@ module Rails
11
11
  COMMANDS_DIR = ".claude/commands".freeze
12
12
  INDEX_PATH = "#{HYPERDRIVE_DIR}/index.md".freeze
13
13
  LOCK_PATH = ".hyperdrive/lock.yml".freeze
14
+ CONFIG_PATH = ".hyperdrive/config.yml".freeze
14
15
 
15
16
  DEFAULT_TARGET = :claude
16
17
 
@@ -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