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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -1
- data/README.md +81 -21
- data/lib/generators/hyperdrive/install/USAGE +4 -2
- data/lib/generators/hyperdrive/install/install_generator.rb +47 -1
- data/lib/generators/hyperdrive/install_summary.rb +1 -1
- data/lib/generators/hyperdrive/sync/USAGE +15 -3
- data/lib/generators/hyperdrive/sync/sync_generator.rb +11 -1
- data/lib/generators/hyperdrive/sync_runner.rb +63 -4
- data/lib/rails/hyperdrive/ancestor_locator.rb +36 -1
- data/lib/rails/hyperdrive/artifact_status.rb +13 -5
- data/lib/rails/hyperdrive/auto_install.rb +17 -8
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +1 -1
- data/lib/rails/hyperdrive/config_file.rb +135 -0
- data/lib/rails/hyperdrive/install_layout.rb +1 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +129 -38
- data/lib/rails/hyperdrive/install_plan.rb +6 -6
- data/lib/rails/hyperdrive/lock_file.rb +72 -50
- data/lib/rails/hyperdrive/resolve/prompt.md.erb +47 -0
- data/lib/rails/hyperdrive/resolve_prompt.rb +42 -0
- data/lib/rails/hyperdrive/sidecar_resolver.rb +215 -0
- data/lib/rails/hyperdrive/stack_profile.rb +3 -3
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +5 -1
|
@@ -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,
|
|
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?(
|
|
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:
|
|
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: [],
|
|
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
|
|
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
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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::
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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?(
|
|
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,
|
|
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 =
|
|
70
|
-
|
|
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?(
|
|
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
|
|
86
|
-
!source_gem.nil? &&
|
|
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
|