|
13 | 13 | module Dependabot |
14 | 14 | module Cargo |
15 | 15 | class FileUpdater |
16 | | - # rubocop:disable-next Metrics/ClassLength |
| 16 | + # rubocop:disable Metrics/ClassLength |
17 | 17 | class LockfileUpdater |
18 | 18 | extend T::Sig |
19 | 19 |
|
@@ -86,14 +86,55 @@ def run_updates |
86 | 86 | @current_dependency = dependency_to_update |
87 | 87 | next if previous_line_already_replaced? |
88 | 88 |
|
| 89 | + lockfile_before = File.read("Cargo.lock") |
89 | 90 | run_cargo_command( |
90 | 91 | "cargo update -p #{dependency_spec}", |
91 | 92 | fingerprint: "cargo update -p <dependency_spec>" |
92 | 93 | ) |
| 94 | + |
| 95 | + force_precise_update if precise_update_needed?(lockfile_before) |
93 | 96 | end |
94 | 97 | end |
95 | 98 | end |
96 | 99 |
|
| 100 | + sig { void } |
| 101 | + def force_precise_update |
| 102 | + run_cargo_command( |
| 103 | + "cargo update -p #{dependency_spec} --precise #{dependency.version}", |
| 104 | + fingerprint: "cargo update -p <dependency_spec> --precise <version>" |
| 105 | + ) |
| 106 | + end |
| 107 | + |
| 108 | + # `cargo update -p name:version` won't move a package whose bump |
| 109 | + # requires lockstep updates to sibling crates shared with other members |
| 110 | + # (e.g. the `futures` family: 0.3.34 needs `futures-*` at `^0.3.34`). |
| 111 | + # Cargo leaves the line unchanged and a later validation fails, so we |
| 112 | + # retry with `--precise` to force the exact target and cascade the |
| 113 | + # siblings. |
| 114 | + # |
| 115 | + # Only retry on a genuine no-op. Comparing the dependency's entries |
| 116 | + # *and* its incoming edges before/after the plain command distinguishes |
| 117 | + # a stuck line from one Cargo did resolve — including an edge repointed |
| 118 | + # onto an already-present target entry, where forcing `--precise` would |
| 119 | + # wrongly fail. |
| 120 | + sig { params(lockfile_before: String).returns(T::Boolean) } |
| 121 | + def precise_update_needed?(lockfile_before) |
| 122 | + return false if git_dependency? |
| 123 | + |
| 124 | + version = dependency.version |
| 125 | + return false unless version && version_class.correct?(version) |
| 126 | + |
| 127 | + previous_version = dependency.previous_version |
| 128 | + return false unless previous_version && version_class.correct?(previous_version) |
| 129 | + return false if previous_version == version |
| 130 | + |
| 131 | + lockfile_after = File.read("Cargo.lock") |
| 132 | + return false if dependency_move_signature(lockfile_before, dependency) != |
| 133 | + dependency_move_signature(lockfile_after, dependency) |
| 134 | + |
| 135 | + package_version_count(lockfile_after, dependency, previous_version).positive? |
| 136 | + end |
| 137 | + |
97 | 138 | # An earlier command in this run may already have resolved this |
98 | 139 | # dependency's line, in which case `cargo update -p name:version` |
99 | 140 | # would match no package and fail hard. Skip the command when the |
@@ -710,6 +751,68 @@ def dependency_lockfile_entries(lockfile_content, dependency) |
710 | 751 | entries |
711 | 752 | end |
712 | 753 |
|
| 754 | + # A "did this dependency move?" signature: the dependency's own package |
| 755 | + # identity plus its incoming edges. An edge repointed onto an |
| 756 | + # already-present target entry moves the dependency without changing any |
| 757 | + # `[[package]]` block, so identity alone is not a reliable signal. |
| 758 | + sig do |
| 759 | + params(lockfile_content: String, dependency: Dependabot::Dependency) |
| 760 | + .returns(T::Array[String]) |
| 761 | + end |
| 762 | + def dependency_move_signature(lockfile_content, dependency) |
| 763 | + dependency_identity_signature(lockfile_content, dependency) + |
| 764 | + dependency_reference_edges(lockfile_content, dependency) |
| 765 | + end |
| 766 | + |
| 767 | + # Identity of each of the dependency's own `[[package]]` blocks, reduced |
| 768 | + # to the fields that define which crate instance is present: `name`, |
| 769 | + # `version` and `source`. |
| 770 | + # |
| 771 | + # This deliberately excludes the block's outgoing `dependencies` array |
| 772 | + # (and checksum): during a grouped update Cargo may add or drop version |
| 773 | + # qualifiers on this crate's own outgoing edges when a sibling starts or |
| 774 | + # stops coexisting, even though this crate itself did not move. Folding |
| 775 | + # the whole block in would misread that as movement and wrongly suppress |
| 776 | + # the `--precise` fallback, leaving the requested version stuck. |
| 777 | + sig do |
| 778 | + params(lockfile_content: String, dependency: Dependabot::Dependency) |
| 779 | + .returns(T::Array[String]) |
| 780 | + end |
| 781 | + def dependency_identity_signature(lockfile_content, dependency) |
| 782 | + dependency_lockfile_entries(lockfile_content, dependency).map do |entry| |
| 783 | + entry.lines.filter_map do |line| |
| 784 | + stripped = line.strip |
| 785 | + stripped if stripped.match?(/\A(?:name|version|source) = /) |
| 786 | + end.join("\n") |
| 787 | + end.sort |
| 788 | + end |
| 789 | + |
| 790 | + # Incoming edges to the dependency (`"futures"` or `"futures 0.3.33"` |
| 791 | + # inside other packages' `dependencies` arrays), each qualified by the |
| 792 | + # parent package that owns it. Cargo only appends the version when |
| 793 | + # several versions of the crate coexist, so a repointed edge is visible |
| 794 | + # here even when both entries stay in place. Qualifying by parent means a |
| 795 | + # pair of edges swapping targets between two parents (`foo 1` -> `foo 2` |
| 796 | + # in one, `foo 2` -> `foo 1` in another) is still detected as movement |
| 797 | + # rather than cancelling out in a globally-sorted list. |
| 798 | + sig do |
| 799 | + params(lockfile_content: String, dependency: Dependabot::Dependency) |
| 800 | + .returns(T::Array[String]) |
| 801 | + end |
| 802 | + def dependency_reference_edges(lockfile_content, dependency) |
| 803 | + edge_regex = /\A"#{Regexp.escape(dependency.name)}( [^"]+)?",?\z/ |
| 804 | + edges = T.let([], T::Array[String]) |
| 805 | + lockfile_content.scan(LOCKFILE_ENTRY_REGEX) do |
| 806 | + block = Regexp.last_match.to_s |
| 807 | + parent_id = [block[/^name = "[^"]+"$/], block[/^version = "[^"]+"$/]].compact.join(" ") |
| 808 | + block.lines.each do |line| |
| 809 | + stripped = line.strip |
| 810 | + edges << "#{parent_id} => #{stripped}" if stripped.match?(edge_regex) |
| 811 | + end |
| 812 | + end |
| 813 | + edges.sort |
| 814 | + end |
| 815 | + |
713 | 816 | # A git dependency can legitimately resolve to a different commit than |
714 | 817 | # the checker expected (e.g. the tracked branch advanced between the |
715 | 818 | # check and this update). Accept the update when the dependency's git |
@@ -794,6 +897,7 @@ def package_version_count(lockfile_content, dependency, version) |
794 | 897 | end |
795 | 898 | end |
796 | 899 | end |
| 900 | + # rubocop:enable Metrics/ClassLength |
797 | 901 | end |
798 | 902 | end |
799 | 903 | end |
0 commit comments