pray-cli 1.14.0 → 1.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc753f65405f7052d882da07b3293b66c69e60cb26b8fe865919000323f2da1a
4
- data.tar.gz: 91586f374cc5a677b0f5d2b722a4ba5da5912d8bd3664eac0ccd48b864889c65
3
+ metadata.gz: 4754a8028d48136e6617f016890123bddb43a4fe27e87a05cc0da63ee5a22106
4
+ data.tar.gz: 5293f312b5cf2b5adb17d2955b21c447aa7ec1e91b374636b475eef30ad68770
5
5
  SHA512:
6
- metadata.gz: a84c2945dba3386f31dfdfc08d603ecee88d2be1e9898c20a993cd9dba9149ae4966b9cf9e02ba8c28905b7a1c81e2f5158bdf2eff8b36fe7f470e0bbd82ab6a
7
- data.tar.gz: 48f107b5721d69c62b83db0f99247f9c4821eea42f85d3459cac09b7441badc3fa092965f877b1d3ae3fb0d38f391e8f65ae76cc21a6fd1595799dd2de0c6324
6
+ metadata.gz: 4905aa110a4304e404dfe10e763ec4dee9c30e158659a2390e1af4e9be6aaf07e4cfb909c56023c7c622fdfb4689c250cdb40a632596f4033492bb1cbc7ecdb6
7
+ data.tar.gz: 59a29e48784dd55363e8c2f63683fccc388029f4dbd18599a44f8efedb6b939e123e7203f96b1bc751edf2dbed8f49f30ca40842b3d13bdb3304a4d2a6e6e129
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.15.0 (2026-09-15)
4
+
5
+ - Refresh path-fork trees with `pray update` (RFC 0114). Rewrite `spec.upstream` pins with `pray update --latest` when the pin does not admit the latest upstream version. Rewrite Prayfile constraints with `pray update --latest` when they do not admit the registry latest version. `pray update --latest --dry-run` prints the planned rewrite and does not write.
6
+ - Keep `spec.upstream` in the packaged prayspec. Published registry metadata does not copy that pin.
7
+ - Keep a listed package spec in `spec.files` when packing; refuse a repeated or aliased content path in the package archive.
8
+ - Use the current project for a later install after an earlier in-process command in another directory.
9
+
3
10
  ## 1.14.0 (2026-09-14)
4
11
 
5
12
  - Keep an unchanged package version's artifact, first-publish time, and yank when `pray publish` runs again (RFC 0061).
data/lib/pray/archive.rb CHANGED
@@ -4,6 +4,7 @@ require "json"
4
4
  require "open3"
5
5
  require "fileutils"
6
6
  require_relative "archive_unpack"
7
+ require_relative "path_safety"
7
8
 
8
9
  module Pray
9
10
  module Archive
@@ -11,14 +12,23 @@ module Pray
11
12
 
12
13
  def build_package_archive_bytes(package)
13
14
  prayspec_path = Resolve.find_prayspec_file(package.root)
14
- prayspec_name = File.basename(prayspec_path)
15
+ prayspec_name = PathSafety.validate_archive_member_path!(File.basename(prayspec_path))
15
16
  metadata = package_metadata_json(package)
17
+ written_paths = Set.new
18
+ record_archive_path(written_paths, "metadata.json", prayspec_name)
19
+ record_archive_path(written_paths, prayspec_name, prayspec_name)
20
+ package.spec.files.each do |file|
21
+ record_archive_path(written_paths, file, prayspec_name)
22
+ end
16
23
 
17
24
  Dir.mktmpdir("pray-package-") do |staging|
18
25
  File.write(File.join(staging, "metadata.json"), metadata)
19
26
  File.write(File.join(staging, prayspec_name), File.binread(prayspec_path))
20
27
  package.spec.files.each do |file|
21
- destination = File.join(staging, file)
28
+ member = PathSafety.validate_archive_member_path!(file)
29
+ next if member == prayspec_name
30
+
31
+ destination = File.join(staging, member)
22
32
  FileUtils.mkdir_p(File.dirname(destination))
23
33
  File.binwrite(destination, File.binread(File.join(package.root, file)))
24
34
  end
@@ -66,5 +76,14 @@ module Pray
66
76
  def with_binary_process_encoding(&block)
67
77
  ArchiveUnpack.with_binary_process_encoding(&block)
68
78
  end
79
+
80
+ def record_archive_path(written_paths, path, auto_included_prayspec)
81
+ normalized = PathSafety.validate_archive_member_path!(path)
82
+ return if written_paths.add?(normalized)
83
+ return if normalized == auto_included_prayspec
84
+
85
+ raise Error.integrity("duplicate package archive path: #{normalized}")
86
+ end
87
+ private_class_method :record_archive_path
69
88
  end
70
89
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module CLI
5
+ def update_latest_command(package, dry_run:, offline:)
6
+ path = manifest_path
7
+ original_text = Pray.read_manifest_text(path)
8
+ manifest_text = original_text
9
+ preview_options = ResolveOptions.new(
10
+ offline: offline,
11
+ refresh: true,
12
+ refresh_source_revisions: true,
13
+ ignore_locked_versions: true
14
+ )
15
+ project = resolve_current_project(preview_options)
16
+ if package && project.manifest.packages.none? { |entry| entry.name == package }
17
+ raise Error.manifest("package #{package} not found")
18
+ end
19
+
20
+ manifest_updates = []
21
+ project.packages.each do |resolved|
22
+ next if package && resolved.declaration.name != package
23
+ next unless resolved.registry_latest_version
24
+ next if Constraint.version_satisfies(resolved.registry_latest_version, resolved.declaration.constraint)
25
+
26
+ new_constraint = Constraint.latest_constraint_for_package(
27
+ resolved.declaration.constraint,
28
+ resolved.registry_latest_version
29
+ )
30
+ unless Constraint.version_satisfies(resolved.registry_latest_version, new_constraint)
31
+ raise Error.resolution(
32
+ "derived constraint #{new_constraint} does not admit registry latest #{resolved.registry_latest_version} for #{resolved.declaration.name}"
33
+ )
34
+ end
35
+
36
+ manifest_updates << {
37
+ name: resolved.declaration.name,
38
+ from_constraint: resolved.declaration.constraint,
39
+ to_constraint: new_constraint,
40
+ registry_latest_version: resolved.registry_latest_version
41
+ }
42
+ updated = resolved.declaration.dup
43
+ updated.constraint = new_constraint
44
+ manifest_text = Pray.replace_package_declaration(manifest_text, updated)
45
+ end
46
+
47
+ previous = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
48
+ upstream_plans = Upstream.plan_path_upstream_latest_constraints(
49
+ project, previous, package, preview_options
50
+ )
51
+ print_latest_constraint_plans(manifest_updates, upstream_plans)
52
+
53
+ return if dry_run
54
+
55
+ Upstream.apply_path_upstream_latest_constraints(upstream_plans)
56
+ Transaction.write_file(path, manifest_text) if manifest_text != original_text
57
+ unlocked = package ? Set[package] : Set.new
58
+ options = ResolveOptions.new(
59
+ offline: offline,
60
+ refresh: true,
61
+ refresh_source_revisions: true,
62
+ ignore_locked_versions: package.nil?,
63
+ unlocked_packages: unlocked
64
+ )
65
+ current = resolve_current_project(options)
66
+ Upstream.apply_path_upstream_refreshes(current, previous, package, options)
67
+ install_command(
68
+ {
69
+ locked: false,
70
+ frozen: false,
71
+ offline: offline,
72
+ refresh: true,
73
+ ignore_locked_versions: package.nil?,
74
+ unlocked_packages: unlocked
75
+ }
76
+ )
77
+ end
78
+
79
+ def print_latest_constraint_plans(manifest_updates, upstream_plans)
80
+ if manifest_updates.empty? && upstream_plans.empty?
81
+ puts "All package constraints already allow latest versions"
82
+ return
83
+ end
84
+
85
+ manifest_updates.each do |update|
86
+ puts "Prayfile: #{update[:name]} constraint #{update[:from_constraint]} -> #{update[:to_constraint]} " \
87
+ "(registry latest #{update[:registry_latest_version]})"
88
+ end
89
+ upstream_plans.each do |plan|
90
+ puts "#{plan.package_name} upstream #{plan.current_constraint} -> #{plan.new_constraint} (latest #{plan.latest_version})"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -10,7 +10,9 @@ module Pray
10
10
  frozen: flags[:frozen],
11
11
  locked: flags[:locked],
12
12
  offline: flags[:offline],
13
- refresh: flags[:refresh]
13
+ refresh: flags[:refresh],
14
+ ignore_locked_versions: flags[:ignore_locked_versions],
15
+ unlocked_packages: flags[:unlocked_packages]
14
16
  )
15
17
  end
16
18
 
@@ -84,10 +86,10 @@ module Pray
84
86
  end
85
87
 
86
88
  def update_command(arguments)
87
- if arguments.any? { |argument| %w[--dry-run --json].include?(argument) }
89
+ if arguments.include?("--json") || (arguments.include?("--dry-run") && !arguments.include?("--latest"))
88
90
  raise Error.unsupported("this Ruby CLI does not support --dry-run or --json for update; use `pray plan` to preview the current Prayfile")
89
91
  end
90
- if arguments.any? { |argument| %w[--latest --major].include?(argument) }
92
+ if arguments.include?("--major")
91
93
  raise Error.unsupported(
92
94
  "to update beyond the current constraint, edit the package version in Prayfile, then run `pray update`"
93
95
  )
@@ -100,9 +102,29 @@ module Pray
100
102
  raise Error.manifest("package #{package} not found")
101
103
  end
102
104
  end
103
- current = resolve_current_project(ResolveOptions.new(offline: offline))
104
- Upstream.ensure_update_supported!(current.packages, package)
105
- install_command({locked: false, frozen: false, offline: offline, refresh: true})
105
+ if arguments.include?("--latest")
106
+ return update_latest_command(package, dry_run: arguments.include?("--dry-run"), offline: offline)
107
+ end
108
+ unlocked = package ? Set[package] : Set.new
109
+ options = ResolveOptions.new(
110
+ offline: offline,
111
+ refresh: true,
112
+ refresh_source_revisions: true,
113
+ ignore_locked_versions: package.nil?,
114
+ unlocked_packages: unlocked
115
+ )
116
+ current = resolve_current_project(options)
117
+ Upstream.apply_path_upstream_refreshes(current, current.previous_lockfile, package, options)
118
+ install_command(
119
+ {
120
+ locked: false,
121
+ frozen: false,
122
+ offline: offline,
123
+ refresh: true,
124
+ ignore_locked_versions: package.nil?,
125
+ unlocked_packages: unlocked
126
+ }
127
+ )
106
128
  end
107
129
 
108
130
  def unlock_command(name)
data/lib/pray/cli/help.rb CHANGED
@@ -101,7 +101,11 @@ module Pray
101
101
  "update" => <<~TEXT.strip,
102
102
  refresh package versions within constraints
103
103
 
104
- Usage: pray update [package]
104
+ Usage: pray update [package] [--latest] [--latest --dry-run]
105
+
106
+ --latest adjusts constraints to allow the latest package versions.
107
+ --latest also rewrites exact spec.upstream pins in path packages, then refreshes those trees.
108
+ --latest --dry-run prints the planned rewrite and does not write.
105
109
  TEXT
106
110
  "plan" => <<~TEXT.strip,
107
111
  preview install/apply changes
data/lib/pray/cli.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "cli/suggest"
10
10
  require_relative "cli/helpers"
11
11
  require_relative "cli/commands/init"
12
12
  require_relative "cli/commands/workflow"
13
+ require_relative "cli/commands/update_latest"
13
14
  require_relative "cli/commands/packages"
14
15
  require_relative "cli/commands/distribution"
15
16
  require_relative "cli/commands/trust"
@@ -22,6 +23,7 @@ module Pray
22
23
  LOCKFILE_PATH = "Prayfile.lock"
23
24
 
24
25
  def run(arguments)
26
+ previous_context = Invocation.context
25
27
  arguments = arguments.dup
26
28
  if arguments.delete("--no-input")
27
29
  ENV["PRAY_NO_INPUT"] = "1"
@@ -35,6 +37,8 @@ module Pray
35
37
  else
36
38
  dispatch(command)
37
39
  end
40
+ ensure
41
+ Invocation.context = previous_context
38
42
  end
39
43
 
40
44
  def maybe_print_help(arguments)
@@ -13,7 +13,9 @@ module Pray
13
13
  frozen: false,
14
14
  locked: false,
15
15
  offline: false,
16
- refresh: false
16
+ refresh: false,
17
+ ignore_locked_versions: false,
18
+ unlocked_packages: nil
17
19
  )
18
20
  context = Invocation.invocation_context
19
21
  manifest_path = File.expand_path(manifest_path || context.manifest_path)
@@ -25,6 +27,9 @@ module Pray
25
27
  options = ResolveOptions.new(
26
28
  offline: offline,
27
29
  refresh: refresh,
30
+ refresh_source_revisions: refresh,
31
+ ignore_locked_versions: ignore_locked_versions,
32
+ unlocked_packages: unlocked_packages ? Set.new(unlocked_packages) : Set.new,
28
33
  environment: context.environment
29
34
  )
30
35
  allow_git_refresh_fallback = !locked && !frozen
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module PackageSpecRender
5
+ module_function
6
+
7
+ def fork_spec_after_refresh(local, new_upstream, local_prayspec_file, clean_replica, merged_content_paths)
8
+ spec = local.dup
9
+ spec.files = [local_prayspec_file, *merged_content_paths]
10
+ if clean_replica
11
+ spec.exports = new_upstream.exports.dup
12
+ spec.templates = new_upstream.templates.dup
13
+ end
14
+ if spec.upstream
15
+ spec.upstream = PackageUpstream.new(
16
+ name: new_upstream.name,
17
+ constraint: Upstream.next_upstream_constraint(spec.upstream.constraint, new_upstream.version)
18
+ )
19
+ end
20
+ spec
21
+ end
22
+
23
+ def render_package_spec(spec)
24
+ lines = ["Package::Specification.new do |spec|"]
25
+ push_identity_fields(lines, spec)
26
+ push_content_fields(lines, spec)
27
+ push_relation_fields(lines, spec)
28
+ lines << "end"
29
+ lines << ""
30
+ lines.join("\n")
31
+ end
32
+
33
+ def push_identity_fields(lines, spec)
34
+ push_assignment(lines, "name", spec.name)
35
+ push_assignment(lines, "version", spec.version)
36
+ push_optional(lines, "summary", spec.summary)
37
+ push_optional(lines, "description", spec.description)
38
+ push_array(lines, "authors", spec.authors) unless spec.authors.nil? || spec.authors.empty?
39
+ push_optional(lines, "license", spec.license)
40
+ push_optional(lines, "homepage", spec.homepage)
41
+ push_optional(lines, "source_code_uri", spec.source_code_uri)
42
+ push_optional(lines, "changelog_uri", spec.changelog_uri)
43
+ push_optional(lines, "prayfile_version", spec.prayfile_version)
44
+ end
45
+
46
+ def push_content_fields(lines, spec)
47
+ push_array(lines, "files", spec.files)
48
+ push_exports(lines, spec.exports)
49
+ push_named_paths(lines, "skills", spec.skills)
50
+ push_named_paths(lines, "templates", spec.templates)
51
+ push_string_map(lines, "adapters", spec.adapters)
52
+ push_array(lines, "targets", spec.targets) unless spec.targets.nil? || spec.targets.empty?
53
+ end
54
+
55
+ def push_relation_fields(lines, spec)
56
+ (spec.dependencies || []).each do |dependency|
57
+ method = dependency.optional ? "add_optional_dependency" : "add_dependency"
58
+ lines << " spec.#{method} #{quote(dependency.name)}, #{quote(dependency.constraint)}"
59
+ end
60
+ unless spec.metadata.nil? || spec.metadata.empty?
61
+ lines << " spec.metadata = #{literal_map(spec.metadata)}"
62
+ end
63
+ return unless spec.upstream
64
+
65
+ lines << " spec.upstream #{quote(spec.upstream.name)}, #{quote(spec.upstream.constraint)}"
66
+ end
67
+
68
+ def push_assignment(lines, field, value)
69
+ lines << " spec.#{field} = #{quote(value)}"
70
+ end
71
+
72
+ def push_optional(lines, field, value)
73
+ push_assignment(lines, field, value) unless value.nil?
74
+ end
75
+
76
+ def push_array(lines, field, values)
77
+ lines << " spec.#{field} = [#{string_array(values)}]"
78
+ end
79
+
80
+ def push_exports(lines, exports)
81
+ return if exports.nil? || exports.empty?
82
+
83
+ lines << " spec.exports = {"
84
+ exports.each do |name, export|
85
+ fields = ["type: #{quote(export.kind)}", "path: #{quote(export.path)}"]
86
+ fields << "summary: #{quote(export.summary)}" if export.summary
87
+ fields << "only: [#{string_array(export.only)}]" unless export.only.nil? || export.only.empty?
88
+ fields << "except: [#{string_array(export.except)}]" unless export.except.nil? || export.except.empty?
89
+ fields << "default_path: #{quote(export.default_path)}" if export.default_path
90
+ lines << " #{quote(name)} => { #{fields.join(", ")} },"
91
+ end
92
+ lines << " }"
93
+ end
94
+
95
+ def push_named_paths(lines, field, entries)
96
+ return if entries.nil? || entries.empty?
97
+
98
+ lines << " spec.#{field} = {"
99
+ entries.each do |name, entry|
100
+ summary = entry.summary ? ", summary: #{quote(entry.summary)}" : ""
101
+ lines << " #{quote(name)} => { path: #{quote(entry.path)}#{summary} },"
102
+ end
103
+ lines << " }"
104
+ end
105
+
106
+ def push_string_map(lines, field, entries)
107
+ return if entries.nil? || entries.empty?
108
+
109
+ values = entries.map { |name, value| "#{quote(name)} => #{quote(value)}" }.join(", ")
110
+ lines << " spec.#{field} = { #{values} }"
111
+ end
112
+
113
+ def string_array(values)
114
+ Array(values).map { |value| quote(value) }.join(", ")
115
+ end
116
+
117
+ def quote(value)
118
+ escaped = value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")
119
+ %("#{escaped}")
120
+ end
121
+
122
+ def literal_map(entries)
123
+ values = entries.map { |name, value| "#{quote(name)} => #{literal(value)}" }.join(", ")
124
+ "{ #{values} }"
125
+ end
126
+
127
+ def literal(value)
128
+ case value.kind
129
+ when :string then quote(value.value)
130
+ when :symbol then ":#{value.value}"
131
+ when :bool then value.value.to_s
132
+ when :null then "nil"
133
+ when :integer then value.value.to_s
134
+ when :array then "[#{value.value.map { |entry| literal(entry) }.join(", ")}]"
135
+ when :map then literal_map(value.value)
136
+ else quote(value.value)
137
+ end
138
+ end
139
+ private_class_method :push_identity_fields, :push_content_fields, :push_relation_fields,
140
+ :push_assignment, :push_optional, :push_array, :push_exports,
141
+ :push_named_paths, :push_string_map, :string_array, :quote, :literal_map, :literal
142
+ end
143
+ end
@@ -39,20 +39,24 @@ module Pray
39
39
  end
40
40
 
41
41
  def validate_archive_member_path!(path)
42
- cleaned = path.to_s.delete_prefix("./")
43
- if cleaned.empty? || Pathname.new(cleaned).absolute? || cleaned.start_with?("/")
42
+ text = path.to_s.tr("\\", "/")
43
+ if text.empty? || Pathname.new(text).absolute? || text.start_with?("/")
44
44
  raise Error.integrity("package path must be relative: #{path}")
45
45
  end
46
46
 
47
- cleaned.split("/").each do |part|
47
+ parts = []
48
+ text.split("/").each do |part|
48
49
  next if part.empty? || part == "."
49
50
 
50
51
  if part == ".." || part.include?("\0")
51
52
  raise Error.integrity("package path escapes package root: #{path}")
52
53
  end
54
+
55
+ parts << part
53
56
  end
57
+ raise Error.integrity("package path must be relative: #{path}") if parts.empty?
54
58
 
55
- cleaned
59
+ parts.join("/")
56
60
  end
57
61
 
58
62
  def validate_project_relative_path!(value)
@@ -57,5 +57,12 @@ module Pray
57
57
 
58
58
  journal.replace(path, TransactionJournal.snapshot(path), bytes.b)
59
59
  end
60
+
61
+ def remove_file(path)
62
+ journal = Thread.current[:pray_write_transaction]
63
+ return File.delete(path) unless journal
64
+
65
+ journal.replace(path, TransactionJournal.snapshot(path), nil)
66
+ end
60
67
  end
61
68
  end
data/lib/pray/upstream.rb CHANGED
@@ -4,19 +4,6 @@ module Pray
4
4
  module Upstream
5
5
  module_function
6
6
 
7
- def ensure_update_supported!(packages, selected = nil)
8
- unsupported = packages.find do |package|
9
- package.declaration.path && package.spec.upstream &&
10
- (selected.nil? || package.declaration.name == selected)
11
- end
12
- return unless unsupported
13
-
14
- raise Error.unsupported(
15
- "this installation cannot refresh upstream package #{unsupported.declaration.name}; " \
16
- "install pray with Cargo and retry"
17
- )
18
- end
19
-
20
7
  def to_lock_hash(entry)
21
8
  hash = {
22
9
  "name" => entry.name,
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Upstream
5
+ module_function
6
+
7
+ PathUpstreamLatestConstraint = Struct.new(
8
+ :package_name, :upstream_name, :current_constraint, :latest_version, :new_constraint, :package_root,
9
+ keyword_init: true
10
+ )
11
+
12
+ def plan_path_upstream_latest_constraints(project, previous, selected, options)
13
+ user_config = Config.load_user_config
14
+ git_sources = GitSources.prepare_git_sources(
15
+ project.project_root,
16
+ project.manifest.sources,
17
+ previous,
18
+ refresh: options.refresh || options.refresh_source_revisions
19
+ )
20
+ sources = Resolve.source_map(project.manifest.sources)
21
+ plans = []
22
+ project.packages.each do |package|
23
+ next if selected && package.declaration.name != selected
24
+
25
+ plan = plan_one_path_upstream(project, package, sources, git_sources, user_config, previous, options)
26
+ plans << plan if plan
27
+ end
28
+ plans
29
+ end
30
+
31
+ def apply_path_upstream_latest_constraints(plans)
32
+ plans.each do |plan|
33
+ spec_path = Resolve.find_prayspec_file(plan.package_root)
34
+ spec = Pray.parse_package_spec(File.read(spec_path))
35
+ unless spec.upstream
36
+ raise Error.resolution("package #{plan.package_name} has no upstream pin")
37
+ end
38
+
39
+ spec.upstream = PackageUpstream.new(
40
+ name: spec.upstream.name,
41
+ constraint: plan.new_constraint
42
+ )
43
+ Transaction.write_file(spec_path, PackageSpecRender.render_package_spec(spec))
44
+ end
45
+ end
46
+
47
+ def latest_spec_upstream_constraint(current, latest_version)
48
+ derived = Constraint.latest_constraint_for_package(current, latest_version)
49
+ return "= #{latest_version}" if derived.start_with?("=")
50
+
51
+ derived
52
+ end
53
+
54
+ def plan_one_path_upstream(project, package, sources, git_sources, user_config, lockfile, options)
55
+ upstream = package.spec.upstream
56
+ return unless upstream && package.declaration.path
57
+
58
+ source = ResolveSource.implied_source_name(ManifestPackage.new(name: upstream.name), sources)
59
+ resolved = Resolve.resolve_package(
60
+ project.project_root,
61
+ sources,
62
+ git_sources,
63
+ user_config,
64
+ ManifestPackage.new(name: upstream.name, constraint: "*", source: source),
65
+ lockfile,
66
+ options: options
67
+ )
68
+ return if Constraint.version_satisfies(resolved.spec.version, upstream.constraint)
69
+
70
+ new_constraint = latest_spec_upstream_constraint(upstream.constraint, resolved.spec.version)
71
+ unless Constraint.version_satisfies(resolved.spec.version, new_constraint)
72
+ raise Error.resolution(
73
+ "derived constraint #{new_constraint} does not admit latest #{resolved.spec.version} for #{package.declaration.name}"
74
+ )
75
+ end
76
+
77
+ PathUpstreamLatestConstraint.new(
78
+ package_name: package.declaration.name,
79
+ upstream_name: upstream.name,
80
+ current_constraint: upstream.constraint,
81
+ latest_version: resolved.spec.version,
82
+ new_constraint: new_constraint,
83
+ package_root: package.root
84
+ )
85
+ end
86
+ private_class_method :plan_one_path_upstream
87
+ end
88
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Upstream
5
+ module_function
6
+
7
+ def identity_path?(path)
8
+ File.extname(path.to_s) == ".prayspec"
9
+ end
10
+
11
+ def content_paths(files)
12
+ files.reject { |path| identity_path?(path) }
13
+ end
14
+
15
+ def clean_replica?(old_content, local_content)
16
+ local_content == old_content
17
+ end
18
+
19
+ def try_merge_content_files(old_content, new_content, local_content)
20
+ return [new_content.dup, []] if clean_replica?(old_content, local_content)
21
+
22
+ merged = {}
23
+ conflicts = []
24
+ (old_content.keys + new_content.keys + local_content.keys).uniq.sort.each do |path|
25
+ result = merge_one_content_path(old_content[path], new_content[path], local_content[path])
26
+ if result == :conflict
27
+ conflicts << path
28
+ elsif result != :omit
29
+ merged[path] = result
30
+ end
31
+ end
32
+ [merged, conflicts]
33
+ end
34
+
35
+ def merge_one_content_path(old, new, local)
36
+ case [old.nil?, new.nil?, local.nil?]
37
+ when [false, false, false]
38
+ merge_all_present(old, new, local)
39
+ when [false, true, false]
40
+ (local == old) ? :omit : :conflict
41
+ when [false, false, true]
42
+ (old == new) ? :omit : new
43
+ when [true, false, true]
44
+ new
45
+ when [true, true, false]
46
+ local
47
+ when [true, false, false]
48
+ (local == new) ? new : :conflict
49
+ else
50
+ :conflict
51
+ end
52
+ end
53
+
54
+ def merge_all_present(old, new, local)
55
+ return new if local == new || local == old
56
+ return local if old == new
57
+
58
+ :conflict
59
+ end
60
+
61
+ def merge_content_files(old_content, new_content, local_content)
62
+ merged, conflicts = try_merge_content_files(old_content, new_content, local_content)
63
+ return merged if conflicts.empty?
64
+
65
+ raise Error.resolution("upstream merge conflict in #{conflicts.join(", ")}")
66
+ end
67
+
68
+ def merge_conflict_message(fork, upstream_name, old_version, new_version, paths)
69
+ "upstream merge conflict in #{fork} while refreshing #{upstream_name} " \
70
+ "#{old_version} to #{new_version}: #{paths.join(", ")}"
71
+ end
72
+
73
+ def next_upstream_constraint(current, new_version)
74
+ trimmed = current.to_s.strip
75
+ return current if trimmed == "*" || trimmed.start_with?("~>", "^", ">=", ">", "<=", "<")
76
+
77
+ "= #{new_version}"
78
+ end
79
+ private_class_method :merge_one_content_path, :merge_all_present
80
+ end
81
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Upstream
5
+ module_function
6
+
7
+ def apply_path_upstream_refreshes(project, previous, selected, options)
8
+ user_config = Config.load_user_config
9
+ git_sources = GitSources.prepare_git_sources(
10
+ project.project_root,
11
+ project.manifest.sources,
12
+ previous,
13
+ refresh: options.refresh || options.refresh_source_revisions
14
+ )
15
+ sources = Resolve.source_map(project.manifest.sources)
16
+ changed = false
17
+ project.packages.each do |package|
18
+ next if selected && package.declaration.name != selected
19
+ next unless apply_one_path_upstream(project, package, previous, sources, git_sources, user_config, options)
20
+
21
+ changed = true
22
+ end
23
+ changed
24
+ end
25
+
26
+ def apply_one_path_upstream(project, package, previous, sources, git_sources, user_config, options)
27
+ new_upstream = package.upstream
28
+ return false unless new_upstream && package.declaration.path
29
+
30
+ old_upstream = previous&.package&.find { |entry| entry.name == package.declaration.name }&.upstream
31
+ return false unless old_upstream
32
+ return false if old_upstream.version == new_upstream.version && old_upstream.tree_hash == new_upstream.tree_hash
33
+
34
+ old_package, new_package = resolve_upstream_pair(
35
+ project, previous, sources, git_sources, user_config, options, old_upstream, new_upstream
36
+ )
37
+ old_content, local_content, merged = merge_fork_content(package, old_upstream, new_upstream, old_package, new_package)
38
+ write_content_files(package.root, old_content, merged)
39
+ write_refreshed_spec(package, new_package, old_content, local_content, merged)
40
+ true
41
+ end
42
+
43
+ def resolve_upstream_pair(project, previous, sources, git_sources, user_config, options, old_upstream, new_upstream)
44
+ old_package = resolve_named(
45
+ project.project_root, sources, git_sources, user_config, previous, options,
46
+ old_upstream.name, "= #{old_upstream.version}", old_upstream.source
47
+ )
48
+ resolved_old = LockedUpstream.new(
49
+ name: old_upstream.name,
50
+ version: old_package.spec.version,
51
+ source: old_upstream.source,
52
+ tree_hash: old_package.tree_hash,
53
+ artifact_hash: old_package.artifact_hash
54
+ )
55
+ ensure_locked_match!(old_upstream, resolved_old)
56
+ new_package = resolve_named(
57
+ project.project_root, sources, git_sources, user_config, previous, options,
58
+ new_upstream.name, "= #{new_upstream.version}", new_upstream.source
59
+ )
60
+ [old_package, new_package]
61
+ end
62
+
63
+ def merge_fork_content(package, old_upstream, new_upstream, old_package, new_package)
64
+ old_content = content_file_bytes(old_package.root, old_package.spec)
65
+ new_content = content_file_bytes(new_package.root, new_package.spec)
66
+ local_content = content_file_bytes(package.root, package.spec)
67
+ merged, conflicts = try_merge_content_files(old_content, new_content, local_content)
68
+ unless conflicts.empty?
69
+ raise Error.resolution(
70
+ merge_conflict_message(
71
+ package.declaration.name, old_upstream.name, old_upstream.version, new_upstream.version, conflicts
72
+ )
73
+ )
74
+ end
75
+ [old_content, local_content, merged]
76
+ end
77
+
78
+ def write_refreshed_spec(package, new_package, old_content, local_content, merged)
79
+ spec_path = Resolve.find_prayspec_file(package.root)
80
+ updated = PackageSpecRender.fork_spec_after_refresh(
81
+ package.spec,
82
+ new_package.spec,
83
+ File.basename(spec_path),
84
+ clean_replica?(old_content, local_content),
85
+ merged.keys
86
+ )
87
+ Transaction.write_file(spec_path, PackageSpecRender.render_package_spec(updated))
88
+ end
89
+
90
+ def resolve_named(project_root, sources, git_sources, user_config, lockfile, options, name, constraint, source)
91
+ Resolve.resolve_package(
92
+ project_root,
93
+ sources,
94
+ git_sources,
95
+ user_config,
96
+ ManifestPackage.new(name: name, constraint: constraint, source: source),
97
+ lockfile,
98
+ options: options
99
+ )
100
+ end
101
+
102
+ def content_file_bytes(root, spec)
103
+ paths = content_paths(spec.files)
104
+ if paths.length > ResourceLimits::MAX_ARCHIVE_ENTRIES
105
+ raise Error.integrity("package content exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRIES} files")
106
+ end
107
+
108
+ files = {}
109
+ total_bytes = 0
110
+ paths.each do |relative|
111
+ PathSafety.validate_archive_member_path!(relative)
112
+ path = File.join(root, relative)
113
+ raise Error.integrity("package file missing: #{relative}") unless File.file?(path)
114
+
115
+ size = File.size(path)
116
+ if size > ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
117
+ raise Error.integrity("package file exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes: #{relative}")
118
+ end
119
+
120
+ total_bytes += size
121
+ if total_bytes > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
122
+ raise Error.integrity("package content exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes")
123
+ end
124
+
125
+ files[relative] = File.binread(path)
126
+ end
127
+ files
128
+ end
129
+
130
+ def write_content_files(root, old_content, merged)
131
+ (old_content.keys + merged.keys).each { |path| PathSafety.validate_archive_member_path!(path) }
132
+ old_content.each_key do |path|
133
+ next if merged.key?(path)
134
+
135
+ Transaction.remove_file(File.join(root, path))
136
+ end
137
+ merged.each do |relative, bytes|
138
+ Transaction.write_file(File.join(root, relative), bytes)
139
+ end
140
+ end
141
+ private_class_method :apply_one_path_upstream, :resolve_upstream_pair, :merge_fork_content,
142
+ :write_refreshed_spec, :resolve_named
143
+ end
144
+ end
data/lib/pray/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pray
4
- VERSION = "1.14.0"
5
- GENERATED_BY = "pray 1.14.0"
4
+ VERSION = "1.15.0"
5
+ GENERATED_BY = "pray 1.15.0"
6
6
  end
data/lib/pray.rb CHANGED
@@ -25,6 +25,10 @@ require_relative "pray/invocation"
25
25
  require_relative "pray/git_refresh"
26
26
  require_relative "pray/resolve"
27
27
  require_relative "pray/upstream"
28
+ require_relative "pray/upstream_merge"
29
+ require_relative "pray/package_spec_render"
30
+ require_relative "pray/upstream_refresh"
31
+ require_relative "pray/upstream_latest"
28
32
  require_relative "pray/compose_dest"
29
33
  require_relative "pray/render_patch"
30
34
  require_relative "pray/render"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pray-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.14.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -113,6 +113,7 @@ files:
113
113
  - lib/pray/cli/commands/meta.rb
114
114
  - lib/pray/cli/commands/packages.rb
115
115
  - lib/pray/cli/commands/trust.rb
116
+ - lib/pray/cli/commands/update_latest.rb
116
117
  - lib/pray/cli/commands/workflow.rb
117
118
  - lib/pray/cli/help.rb
118
119
  - lib/pray/cli/helpers.rb
@@ -147,6 +148,7 @@ files:
147
148
  - lib/pray/manifest_parser_helpers.rb
148
149
  - lib/pray/materialize.rb
149
150
  - lib/pray/package_spec.rb
151
+ - lib/pray/package_spec_render.rb
150
152
  - lib/pray/path_safety.rb
151
153
  - lib/pray/plan.rb
152
154
  - lib/pray/project_context.rb
@@ -181,6 +183,9 @@ files:
181
183
  - lib/pray/trust_feed.rb
182
184
  - lib/pray/trust_ops.rb
183
185
  - lib/pray/upstream.rb
186
+ - lib/pray/upstream_latest.rb
187
+ - lib/pray/upstream_merge.rb
188
+ - lib/pray/upstream_refresh.rb
184
189
  - lib/pray/verify.rb
185
190
  - lib/pray/verify_position.rb
186
191
  - lib/pray/verify_provisioned.rb