Skip to content

Commit af6d6b2

Browse files
committed
Mark stale fetchable deps for compile before convergence
A fetched git dependency could report {:ok, vsn} from its stale .app before Mix checked the stored SCM manifest lock. If the same dependency then appeared again through a stricter transitive requirement, the converger would mark it as :divergedreq instead of :compile. Validate the SCM manifest while loading the dependency so fetchable deps with a stale stored lock are marked for compilation before convergence reuses their status. Add regression tests for the transitive git case and for the direct converger case. Closes #15406.
1 parent 4dd6c3c commit af6d6b2

4 files changed

Lines changed: 136 additions & 38 deletions

File tree

lib/mix/lib/mix/dep.ex

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,10 @@ defmodule Mix.Dep do
438438
Checks the lock for the given dependency and update its status accordingly.
439439
"""
440440
def check_lock(%Mix.Dep{scm: scm, opts: opts} = dep) do
441-
if available?(dep) do
441+
# We only update the lock if the dependency is compilable or available.
442+
# That's because a dependency may need compilation but if it has the wrong version,
443+
# then the version reporting gets higher priority.
444+
if available?(dep) or compilable?(dep) do
442445
case scm.lock_status(opts) do
443446
:mismatch ->
444447
status = if rev = opts[:lock], do: {:lockmismatch, rev}, else: :nolock
@@ -449,44 +452,13 @@ defmodule Mix.Dep do
449452
%{dep | status: :lockoutdated}
450453

451454
:ok ->
452-
check_manifest(dep)
455+
dep
453456
end
454457
else
455458
dep
456459
end
457460
end
458461

459-
defp check_manifest(%{scm: scm, opts: opts} = dep) do
460-
vsn = {System.version(), :erlang.system_info(:otp_release)}
461-
lock = opts[:lock]
462-
463-
case Mix.Dep.ElixirSCM.read(Path.join(opts[:build], ".mix")) do
464-
{:ok, old_vsn, _, _} when old_vsn != vsn ->
465-
%{dep | status: {:vsnlock, old_vsn}}
466-
467-
{:ok, _, old_scm, _} when old_scm != scm ->
468-
%{dep | status: {:scmlock, old_scm}}
469-
470-
{:ok, _, _, old_lock} when old_lock != lock ->
471-
if scm.fetchable?() do
472-
%{dep | status: :compile}
473-
else
474-
dep
475-
end
476-
477-
:error ->
478-
if scm.fetchable?() do
479-
%{dep | status: :compile}
480-
else
481-
dep
482-
end
483-
484-
# If the file is missing, it is handled in the loader
485-
_ ->
486-
dep
487-
end
488-
end
489-
490462
@doc """
491463
Returns `true` if the dependency is ok.
492464
"""

lib/mix/lib/mix/dep/loader.ex

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ defmodule Mix.Dep.Loader do
110110
{dep, []}
111111
end
112112

113-
validate_app(%{dep | deps: attach_only_and_targets(children, opts)})
113+
%{dep | deps: attach_only_and_targets(children, opts)}
114+
|> validate_manifest()
115+
|> validate_app()
114116
end
115117

116118
@doc """
@@ -410,6 +412,33 @@ defmodule Mix.Dep.Loader do
410412
end
411413
end
412414

415+
defp validate_manifest(dep) do
416+
if ok?(dep) do
417+
%{scm: scm, opts: opts} = dep
418+
vsn = {System.version(), :erlang.system_info(:otp_release)}
419+
lock = opts[:lock]
420+
421+
case Mix.Dep.ElixirSCM.read(Path.join(opts[:build], ".mix")) do
422+
{:ok, old_vsn, _, _} when old_vsn != vsn ->
423+
%{dep | status: {:vsnlock, old_vsn}}
424+
425+
{:ok, _, old_scm, _} when old_scm != scm ->
426+
%{dep | status: {:scmlock, old_scm}}
427+
428+
{:ok, _, _, old_lock} when old_lock != lock ->
429+
if scm.fetchable?(), do: %{dep | status: :compile}, else: dep
430+
431+
:error ->
432+
if scm.fetchable?(), do: %{dep | status: :compile}, else: dep
433+
434+
_ ->
435+
dep
436+
end
437+
else
438+
dep
439+
end
440+
end
441+
413442
defp app_status(app_path, app, req) do
414443
case Mix.AppLoader.read_app(app, app_path) do
415444
{:ok, properties} ->

lib/mix/test/mix/dep_test.exs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,36 @@ defmodule Mix.DepTest do
408408
end)
409409
end
410410

411+
test "marks fetchable dep for compile when stored lock is stale" do
412+
deps = [{:git_repo, "0.1.0", git: MixTest.Case.fixture_path("git_repo")}]
413+
414+
with_deps(deps, fn ->
415+
in_fixture("no_mixfile", fn ->
416+
File.mkdir_p!("deps")
417+
File.cp_r!(fixture_path("git_repo"), "deps/git_repo")
418+
419+
File.mkdir_p!("_build/dev/lib/git_repo/ebin")
420+
File.mkdir_p!("_build/dev/lib/git_repo/.mix")
421+
422+
File.write!("_build/dev/lib/git_repo/ebin/git_repo.app", """
423+
{application, git_repo, [
424+
{vsn,"0.1.0"}
425+
]}.
426+
""")
427+
428+
manifest_data =
429+
:erlang.term_to_binary(
430+
{2, {System.version(), :erlang.system_info(:otp_release)}, Mix.SCM.Git, :stale_lock}
431+
)
432+
433+
File.write!("_build/dev/lib/git_repo/.mix/compile.elixir_scm", manifest_data)
434+
435+
[git_repo] = Mix.Dep.Converger.converge([])
436+
assert git_repo.status == :compile
437+
end)
438+
end)
439+
end
440+
411441
## Remote converger
412442

413443
defmodule IdentityRemoteConverger do

lib/mix/test/mix/tasks/deps.git_test.exs

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -711,14 +711,81 @@ defmodule Mix.Tasks.DepsGitTest do
711711
message = "* Getting git_repo (#{fixture_path("git_repo")})"
712712
assert_received {:mix_shell, :info, [^message]}
713713
assert_shallow("deps/git_repo", 1)
714-
715714
assert File.read!("mix.lock") =~ "submodules: true"
716-
# TODO: assert submodule is not shallow. This would likely require
717-
# changes to the fixtures. Apparently, not even the submodules-specific
718-
# tests check that the cloned repo contains submodules as expected.
719715
end)
720716
end
721717

718+
test "stale .app for a fetchable dep does not surface as :divergedreq via the converger" do
719+
# Reproduces the convergence chain seen in hexpm/hex#1166:
720+
#
721+
# 1. validate_app reads `_build/.../git_repo.app` (stale vsn 0.1.0)
722+
# against the top-level requirement "0.1.0" and returns
723+
# {:ok, "0.1.0"}.
724+
# 2. The converger then encounters git_repo a second time, this
725+
# time as a child of `deps_on_git_repo`, whose requirement
726+
# "~> 0.2.0" does not match the cached {:ok, "0.1.0"}.
727+
# `req_mismatch` fires and the dep is marked :divergedreq.
728+
# 3. `show_diverged!` raises before the lock-in-manifest check
729+
# in `check_manifest` gets a chance to flag the build as stale.
730+
#
731+
# With the loader bypass, step 1 returns :compile instead of
732+
# {:ok, _}, `req_mismatch` returns nil, and the dep ends up flagged
733+
# for recompile rather than as a spurious requirement conflict.
734+
in_fixture("no_mixfile", fn ->
735+
File.cp_r!(fixture_path("deps_on_git_repo"), "deps_on_git_repo")
736+
737+
update_deps_on_git_repo = fn req ->
738+
File.write!(
739+
"deps_on_git_repo/mix.exs",
740+
File.read!("deps_on_git_repo/mix.exs")
741+
|> String.replace(
742+
~r/\{:git_repo, (?:".*?", )?git: MixTest\.Case\.fixture_path\("git_repo"\)\}/,
743+
"{:git_repo, #{inspect(req)}, git: MixTest.Case.fixture_path(\"git_repo\")}"
744+
)
745+
)
746+
end
747+
748+
# Bootstrap with a matching requirement so deps.get + compile succeed
749+
# and the SCM manifest records the current lock alongside vsn 0.1.0.
750+
update_deps_on_git_repo.("0.1.0")
751+
752+
Mix.ProjectStack.post_config(
753+
deps: [
754+
{:git_repo, "0.1.0", git: fixture_path("git_repo")},
755+
{:deps_on_git_repo, path: "deps_on_git_repo"}
756+
]
757+
)
758+
759+
Mix.Project.push(MixTest.Case.Sample)
760+
761+
Mix.Tasks.Deps.Get.run([])
762+
Mix.Tasks.Deps.Compile.run([])
763+
764+
# Now simulate the post-fetch state:
765+
# - The transitive parent has moved on to a stricter requirement
766+
# that the build's stale .app vsn ("0.1.0") no longer satisfies.
767+
# - The SCM manifest's stored lock no longer matches opts[:lock],
768+
# signalling that _build is behind the fetched source.
769+
update_deps_on_git_repo.("~> 0.2.0")
770+
771+
manifest = "_build/dev/lib/git_repo/.mix/compile.elixir_scm"
772+
{2, vsn, scm, _lock} = manifest |> File.read!() |> :erlang.binary_to_term()
773+
File.write!(manifest, :erlang.term_to_binary({2, vsn, scm, :stale_lock}))
774+
775+
Mix.Task.clear()
776+
Mix.State.clear_cache()
777+
purge([GitRepo, GitRepo.MixProject, DepsOnGitRepo.MixProject])
778+
779+
[git_repo_dep] =
780+
Mix.Dep.load_and_cache() |> Enum.filter(&(&1.app == :git_repo))
781+
782+
assert git_repo_dep.status == :compile
783+
refute Mix.Dep.diverged?(git_repo_dep)
784+
end)
785+
after
786+
purge([GitRepo, GitRepo.MixProject, DepsOnGitRepo.MixProject])
787+
end
788+
722789
defp update_dep(git_repo_opts) do
723790
# Flush the errors we got, move to a clean slate
724791
Mix.shell().flush()

0 commit comments

Comments
 (0)