Skip to content

Commit b31ffc5

Browse files
committed
Implement lockstep update for shared dependencies in Cargo
- Enhance lockfile updater to force precise updates for dependencies requiring coordinated version changes. - Add tests to validate behavior for shared dependency families in various workspace scenarios. - Introduce necessary fixture files for testing lockstep updates across multiple crates.
1 parent 57e6825 commit b31ffc5

8 files changed

Lines changed: 892 additions & 1 deletion

File tree

‎cargo/lib/dependabot/cargo/file_updater/lockfile_updater.rb‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
module Dependabot
1414
module Cargo
1515
class FileUpdater
16-
# rubocop:disable-next Metrics/ClassLength
16+
# rubocop:disable Metrics/ClassLength
1717
class LockfileUpdater
1818
extend T::Sig
1919

@@ -86,14 +86,55 @@ def run_updates
8686
@current_dependency = dependency_to_update
8787
next if previous_line_already_replaced?
8888

89+
lockfile_before = File.read("Cargo.lock")
8990
run_cargo_command(
9091
"cargo update -p #{dependency_spec}",
9192
fingerprint: "cargo update -p <dependency_spec>"
9293
)
94+
95+
force_precise_update if precise_update_needed?(lockfile_before)
9396
end
9497
end
9598
end
9699

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+
97138
# An earlier command in this run may already have resolved this
98139
# dependency's line, in which case `cargo update -p name:version`
99140
# would match no package and fail hard. Skip the command when the
@@ -710,6 +751,68 @@ def dependency_lockfile_entries(lockfile_content, dependency)
710751
entries
711752
end
712753

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+
713816
# A git dependency can legitimately resolve to a different commit than
714817
# the checker expected (e.g. the tracked branch advanced between the
715818
# check and this update). Accept the update when the dependency's git
@@ -794,6 +897,7 @@ def package_version_count(lockfile_content, dependency, version)
794897
end
795898
end
796899
end
900+
# rubocop:enable Metrics/ClassLength
797901
end
798902
end
799903
end

0 commit comments

Comments
 (0)