Skip to content

Commit 8cd49b3

Browse files
committed
mix deps: Add :depth option to git deps
This allows for faster clones that transfer less data over the network and take less space in disk, for cases when the full history is not needed.
1 parent e58f568 commit 8cd49b3

File tree

4 files changed

+242
-7
lines changed

4 files changed

+242
-7
lines changed

lib/mix/lib/mix/scm/git.ex

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,18 @@ defmodule Mix.SCM.Git do
125125
sparse_toggle(opts)
126126
update_origin(opts[:git])
127127

128+
rev = get_lock_rev(opts[:lock], opts) || get_opts_rev(opts)
129+
128130
# Fetch external data
129131
["--git-dir=.git", "fetch", "--force", "--quiet"]
130132
|> Kernel.++(progress_switch(git_version()))
131133
|> Kernel.++(tags_switch(opts[:tag]))
134+
|> Kernel.++(depth_switch(opts[:depth]))
135+
|> Kernel.++(if rev, do: ["origin", rev], else: [])
132136
|> git!()
133137

134138
# Migrate the Git repo
135-
rev = get_lock_rev(opts[:lock], opts) || get_opts_rev(opts) || default_branch()
139+
rev = rev || default_branch()
136140
git!(["--git-dir=.git", "checkout", "--quiet", rev])
137141

138142
if opts[:submodules] do
@@ -181,12 +185,17 @@ defmodule Mix.SCM.Git do
181185
end
182186

183187
@min_git_version_sparse {1, 7, 4}
188+
@min_git_version_depth {1, 5, 0}
184189
@min_git_version_progress {1, 7, 1}
185190

186191
defp check_sparse_support(version) do
187192
ensure_feature_compatibility(version, @min_git_version_sparse, "sparse checkout")
188193
end
189194

195+
defp check_depth_support(version) do
196+
ensure_feature_compatibility(version, @min_git_version_depth, "depth (shallow clone)")
197+
end
198+
190199
defp ensure_feature_compatibility(version, required_version, feature) do
191200
unless required_version <= version do
192201
Mix.raise(
@@ -203,11 +212,19 @@ defmodule Mix.SCM.Git do
203212
defp tags_switch(nil), do: []
204213
defp tags_switch(_), do: ["--tags"]
205214

215+
defp depth_switch(nil), do: []
216+
217+
defp depth_switch(n) when is_integer(n) and n > 0 do
218+
check_depth_support(git_version())
219+
["--depth=#{n}"]
220+
end
221+
206222
## Helpers
207223

208224
defp validate_git_options(opts) do
209225
opts
210226
|> validate_refspec()
227+
|> validate_depth()
211228
end
212229

213230
defp validate_refspec(opts) do
@@ -232,6 +249,22 @@ defmodule Mix.SCM.Git do
232249
end
233250
end
234251

252+
defp validate_depth(opts) do
253+
case Keyword.take(opts, [:depth]) do
254+
[] ->
255+
opts
256+
257+
[{:depth, depth}] when is_integer(depth) and depth > 0 ->
258+
opts
259+
260+
invalid_depth ->
261+
Mix.raise(
262+
"The depth must be a positive integer, and be specified only once, got: #{inspect(invalid_depth)}. " <>
263+
"Error on Git dependency: #{redact_uri(opts[:git])}"
264+
)
265+
end
266+
end
267+
235268
defp get_lock(opts) do
236269
%{rev: rev} = get_rev_info()
237270
{:git, opts[:git], rev, get_lock_opts(opts)}
@@ -248,7 +281,7 @@ defmodule Mix.SCM.Git do
248281
defp get_lock_rev(_, _), do: nil
249282

250283
defp get_lock_opts(opts) do
251-
lock_opts = Keyword.take(opts, [:branch, :ref, :tag, :sparse, :subdir])
284+
lock_opts = Keyword.take(opts, [:branch, :ref, :tag, :sparse, :subdir, :depth])
252285

253286
if opts[:submodules] do
254287
lock_opts ++ [submodules: true]
@@ -258,11 +291,7 @@ defmodule Mix.SCM.Git do
258291
end
259292

260293
defp get_opts_rev(opts) do
261-
if branch = opts[:branch] do
262-
"origin/#{branch}"
263-
else
264-
opts[:ref] || opts[:tag]
265-
end
294+
opts[:branch] || opts[:ref] || opts[:tag]
266295
end
267296

268297
defp redact_uri(git) do
@@ -292,6 +321,8 @@ defmodule Mix.SCM.Git do
292321
end
293322

294323
defp default_branch() do
324+
# Note: the `set-head -a` command requires the remote reference to be
325+
# fetched first.
295326
git!(["--git-dir=.git", "remote", "set-head", "origin", "-a"])
296327
"origin/HEAD"
297328
end
@@ -345,6 +376,7 @@ defmodule Mix.SCM.Git do
345376

346377
[]
347378
|> Kernel.++(if git_version < @min_git_version_sparse, do: [:sparse], else: [])
379+
|> Kernel.++(if git_version < @min_git_version_depth, do: [:depth], else: [])
348380
end
349381

350382
defp git_version do

lib/mix/lib/mix/tasks/deps.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ defmodule Mix.Tasks.Deps do
120120
* `:subdir` - (since v1.13.0) search for the project in the given directory
121121
relative to the git checkout. This is similar to `:sparse` option but instead
122122
of a doing a sparse checkout it does a full checkout.
123+
* `:depth` - (since v1.17.0) creates a shallow clone of the Git repository,
124+
limiting the history to the specified number of commits. This can significantly
125+
improve clone speed for large repositories when full history is not needed.
126+
The value must be a positive integer, typically `1`.
123127
124128
If your Git repository requires authentication, such as basic username:password
125129
HTTP authentication via URLs, it can be achieved via Git configuration, keeping

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

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,204 @@ defmodule Mix.Tasks.DepsGitTest do
478478
purge([GitRepo, GitRepo.MixProject])
479479
end
480480

481+
describe "Git depth option" do
482+
@describetag :git_depth
483+
484+
test "gets and updates Git repos with depth option" do
485+
Process.put(:git_repo_opts, depth: 1)
486+
487+
in_fixture("no_mixfile", fn ->
488+
Mix.Project.push(GitApp)
489+
490+
Mix.Tasks.Deps.Get.run([])
491+
message = "* Getting git_repo (#{fixture_path("git_repo")})"
492+
assert_received {:mix_shell, :info, [^message]}
493+
assert_shallow("deps/git_repo", 1)
494+
495+
# Expand depth
496+
update_dep(depth: 2)
497+
Mix.Tasks.Deps.Get.run([])
498+
assert_shallow("deps/git_repo", 2)
499+
500+
# Reduce depth
501+
update_dep(depth: 1)
502+
Mix.Tasks.Deps.Get.run([])
503+
assert_shallow("deps/git_repo", 1)
504+
end)
505+
end
506+
507+
test "with tag" do
508+
Process.put(:git_repo_opts, depth: 1, tag: "with_module")
509+
510+
in_fixture("no_mixfile", fn ->
511+
Mix.Project.push(GitApp)
512+
513+
Mix.Tasks.Deps.Get.run([])
514+
message = "* Getting git_repo (#{fixture_path("git_repo")} - with_module)"
515+
assert_received {:mix_shell, :info, [^message]}
516+
assert_shallow("deps/git_repo", 1)
517+
end)
518+
end
519+
520+
test "with branch" do
521+
Process.put(:git_repo_opts, depth: 1, branch: "main")
522+
523+
in_fixture("no_mixfile", fn ->
524+
Mix.Project.push(GitApp)
525+
526+
Mix.Tasks.Deps.Get.run([])
527+
message = "* Getting git_repo (#{fixture_path("git_repo")} - main)"
528+
assert_received {:mix_shell, :info, [^message]}
529+
assert_shallow("deps/git_repo", 1)
530+
end)
531+
end
532+
533+
test "with ref" do
534+
[last, _ | _] = get_git_repo_revs("git_repo")
535+
536+
Process.put(:git_repo_opts, depth: 1, ref: last)
537+
538+
in_fixture("no_mixfile", fn ->
539+
Mix.Project.push(GitApp)
540+
541+
Mix.Tasks.Deps.Get.run([])
542+
message = "* Getting git_repo (#{fixture_path("git_repo")} - #{last})"
543+
assert_received {:mix_shell, :info, [^message]}
544+
assert_shallow("deps/git_repo", 1)
545+
end)
546+
end
547+
548+
test "changing refspec updates retaining depth" do
549+
[last, first | _] = get_git_repo_revs("git_repo")
550+
551+
Process.put(:git_repo_opts, ref: first, depth: 1)
552+
553+
in_fixture("no_mixfile", fn ->
554+
Mix.Project.push(GitApp)
555+
556+
Mix.Tasks.Deps.Get.run([])
557+
message = "* Getting git_repo (#{fixture_path("git_repo")} - #{first})"
558+
assert_received {:mix_shell, :info, [^message]}
559+
assert_shallow("deps/git_repo", 1)
560+
assert File.read!("mix.lock") =~ first
561+
562+
# Change refspec
563+
update_dep(ref: last, depth: 1)
564+
Mix.Tasks.Deps.Get.run([])
565+
assert_shallow("deps/git_repo", 1)
566+
assert File.read!("mix.lock") =~ last
567+
end)
568+
end
569+
570+
test "removing depth retains shallow repository" do
571+
# The expectation is that one needs to deps.clean and deps.get the
572+
# dependency for a full clone.
573+
Process.put(:git_repo_opts, depth: 1)
574+
575+
in_fixture("no_mixfile", fn ->
576+
Mix.Project.push(GitApp)
577+
578+
Mix.Tasks.Deps.Get.run([])
579+
message = "* Getting git_repo (#{fixture_path("git_repo")})"
580+
assert_received {:mix_shell, :info, [^message]}
581+
assert_shallow("deps/git_repo", 1)
582+
583+
# Remove depth
584+
update_dep([])
585+
Mix.Tasks.Deps.Get.run([])
586+
refute File.read!("mix.lock") =~ "depth:"
587+
assert File.exists?("deps/git_repo/.git/shallow")
588+
589+
assert System.cmd("git", ~w[--git-dir=deps/git_repo/.git rev-list --count HEAD]) ==
590+
{"1\n", 0}
591+
end)
592+
end
593+
594+
@tag :git_sparse
595+
test "with sparse checkout" do
596+
Process.put(:git_repo_opts, sparse: "sparse_dir", depth: 1)
597+
598+
in_fixture("no_mixfile", fn ->
599+
Mix.Project.push(GitApp)
600+
601+
Mix.Tasks.Deps.Get.run([])
602+
message = "* Getting git_repo (#{fixture_path("git_repo")})"
603+
assert_received {:mix_shell, :info, [^message]}
604+
assert_shallow("deps/git_repo", 1)
605+
606+
refute File.exists?("deps/git_repo/mix.exs")
607+
assert File.exists?("deps/git_repo/sparse_dir/mix.exs")
608+
assert File.read!("mix.lock") =~ "sparse: \"sparse_dir\""
609+
end)
610+
end
611+
612+
test "with subdir" do
613+
Process.put(:git_repo_opts, subdir: "sparse_dir", depth: 1)
614+
615+
in_fixture("no_mixfile", fn ->
616+
Mix.Project.push(GitApp)
617+
618+
Mix.Tasks.Deps.Get.run([])
619+
message = "* Getting git_repo (#{fixture_path("git_repo")})"
620+
assert_received {:mix_shell, :info, [^message]}
621+
assert_shallow("deps/git_repo", 1)
622+
623+
assert File.exists?("deps/git_repo/mix.exs")
624+
assert File.exists?("deps/git_repo/sparse_dir/mix.exs")
625+
assert File.read!("mix.lock") =~ "subdir: \"sparse_dir\""
626+
end)
627+
end
628+
629+
test "does not affect submodules depth" do
630+
# The expectation is that we can add an explicit option in the future,
631+
# just like git-clone has `--shallow-submodules`.
632+
Process.put(:git_repo_opts, submodules: true, depth: 1)
633+
634+
in_fixture("no_mixfile", fn ->
635+
Mix.Project.push(GitApp)
636+
637+
Mix.Tasks.Deps.Get.run([])
638+
message = "* Getting git_repo (#{fixture_path("git_repo")})"
639+
assert_received {:mix_shell, :info, [^message]}
640+
assert_shallow("deps/git_repo", 1)
641+
642+
assert File.read!("mix.lock") =~ "submodules: true"
643+
# TODO: assert submodule is not shallow. This would likely require
644+
# changes to the fixtures. Apparently, not even the submodules-specific
645+
# tests check that the cloned repo contains submodules as expected.
646+
end)
647+
end
648+
649+
defp update_dep(git_repo_opts) do
650+
# Flush the errors we got, move to a clean slate
651+
Mix.shell().flush()
652+
Mix.Task.clear()
653+
Process.put(:git_repo_opts, git_repo_opts)
654+
Mix.Project.pop()
655+
Mix.Project.push(GitApp)
656+
end
657+
658+
defp assert_shallow(repo_path, depth) do
659+
assert File.read!("mix.lock") =~ "depth: #{depth}"
660+
661+
# Check if the repository is a shallow clone
662+
assert File.exists?(repo_path <> "/.git/shallow")
663+
664+
# Check the number of commits in the current branch.
665+
#
666+
# We could consider all branches with `git rev-list --count --all`, as in
667+
# practice there should be only a single branch. However, the test fixture
668+
# sets up two branches, and that brings us to an interesting situation:
669+
# instead of guaranteeing that the `:depth` option would keep the
670+
# repository lean even after refspec changes, we only guarantee the number
671+
# of commits in the current branch, perhaps leaving more objects around
672+
# than strictly necessary. This allows us to keep the implementation
673+
# simple, while still providing a reasonable guarantee.
674+
assert System.cmd("git", ~w[--git-dir=#{repo_path}/.git rev-list --count HEAD]) ==
675+
{"#{depth}\n", 0}
676+
end
677+
end
678+
481679
defp refresh(post_config) do
482680
%{name: name, file: file} = Mix.Project.pop()
483681
Mix.ProjectStack.post_config(post_config)

lib/mix/test/test_helper.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ git_exclude =
2727
Mix.SCM.Git.unsupported_options()
2828
|> Enum.map(fn
2929
:sparse -> {:git_sparse, true}
30+
:depth -> {:git_depth, true}
3031
end)
3132

3233
{line_exclude, line_include} =

0 commit comments

Comments
 (0)