9.1 KiB · text History 6280797
defmodule GitGud.ImportsSyncTest do
@moduledoc """
Exercises the parts of the importer that actually touch git: the clone
worker's bookkeeping and upstream re-sync.
"""
use GitGud.DataCase, async: false
use Oban.Testing, repo: GitGud.Repo
alias GitGud.Git
alias GitGud.Imports
alias GitGud.Imports.ImportItem
alias GitGud.Imports.Workers.CloneRepo
alias GitGud.Repositories
import GitGud.AccountsFixtures
import GitGud.ForgeFixtures
@moduletag :git_required
setup do
if System.find_executable("git") == nil do
{:skip, "git not on PATH"}
else
:ok
end
end
describe "sync_upstream/2" do
test "pulls new commits from upstream and refreshes the ref cache" do
{_owner, upstream} = repository_fixture()
sha1 = seed_commit(upstream)
{_user, local} = repository_fixture()
local = set_upstream(local, upstream.disk_path)
assert {:ok, %{changes: changes}} = Repositories.sync_upstream(local)
assert {"refs/heads/main"} = {elem(hd(changes), 2)}
assert {:ok, ^sha1} = Git.resolve(local.disk_path, "main")
# A second sync with nothing new upstream is a clean no-op.
assert {:ok, %{changes: []}} = Repositories.sync_upstream(local)
# New commit upstream lands on the next sync.
sha2 = add_commit(upstream, sha1)
assert {:ok, %{changes: [{_old, _new, "refs/heads/main"}]}} =
Repositories.sync_upstream(local)
assert {:ok, ^sha2} = Git.resolve(local.disk_path, "main")
end
test "prunes refs deleted upstream" do
{_owner, upstream} = repository_fixture()
sha = seed_commit(upstream)
:ok = Git.update_ref(upstream.disk_path, "refs/heads/doomed", sha, nil)
{_user, local} = repository_fixture()
local = set_upstream(local, upstream.disk_path)
assert {:ok, _} = Repositories.sync_upstream(local)
assert {:ok, _} = Git.resolve(local.disk_path, "doomed")
{_, 0} =
System.cmd("git", ["-C", upstream.disk_path, "update-ref", "-d", "refs/heads/doomed"])
assert {:ok, %{changes: changes}} = Repositories.sync_upstream(local)
assert Enum.any?(changes, fn {_old, _new, ref} -> ref == "refs/heads/doomed" end)
refute match?({:ok, _}, Git.resolve(local.disk_path, "doomed"))
end
test "stamps upstream_synced_at" do
{_owner, upstream} = repository_fixture()
_ = seed_commit(upstream)
{_user, local} = repository_fixture()
local = set_upstream(local, upstream.disk_path)
assert local.upstream_synced_at == nil
assert {:ok, %{repository: synced}} = Repositories.sync_upstream(local)
assert synced.upstream_synced_at != nil
end
test "reports a git failure instead of raising" do
{_user, local} = repository_fixture()
local = set_upstream(local, Path.join(System.tmp_dir!(), "definitely-not-a-repo"))
assert {:error, {:git_fetch, out}} = Repositories.sync_upstream(local)
assert is_binary(out)
end
end
describe "credential hygiene" do
test "sanitize_upstream_url strips embedded credentials" do
assert Repositories.sanitize_upstream_url("https://user:token@example.com/a/b.git") ==
"https://example.com/a/b.git"
assert Repositories.sanitize_upstream_url("https://token@example.com/a/b.git") ==
"https://example.com/a/b.git"
# Anything without userinfo is passed through untouched.
assert Repositories.sanitize_upstream_url("https://example.com/a/b.git") ==
"https://example.com/a/b.git"
assert Repositories.sanitize_upstream_url(nil) == nil
end
test "an imported repo records its upstream without credentials" do
{_owner, upstream} = repository_fixture()
_ = seed_commit(upstream)
user = user_fixture()
# A local path stands in for the remote; the point is that whatever
# we clone from is what lands in `upstream_url`, sanitized.
{:ok, repo} =
Repositories.create_repository(user, %{
"name" => "imported#{System.unique_integer([:positive])}",
"clone_addr" => upstream.disk_path
})
assert repo.upstream_url == upstream.disk_path
assert {:ok, _} = Git.resolve(repo.disk_path, "main")
end
end
describe "CloneRepo worker" do
test "marks an item skipped when the name is taken, and settles the batch" do
{user, existing} = repository_fixture()
{:ok, import_row} =
Imports.start_import(user, user, base_attrs(), [discovered(existing.name)], nil)
[item] = import_row.items
assert :ok = perform_job(CloneRepo, %{"item_id" => item.id})
assert Repo.get(ImportItem, item.id).status == "skipped"
assert Imports.get_import(import_row.id).status == "completed"
end
test "does nothing for an item that already finished" do
user = user_fixture()
{:ok, import_row} = Imports.start_import(user, user, base_attrs(), [discovered("a")], nil)
[item] = import_row.items
_ = Imports.mark_item(item, "imported")
assert :ok = perform_job(CloneRepo, %{"item_id" => item.id})
assert Repo.get(ImportItem, item.id).status == "imported"
end
test "skips remaining items once the batch is canceled" do
user = user_fixture()
{:ok, import_row} = Imports.start_import(user, user, base_attrs(), [discovered("a")], nil)
[item] = import_row.items
{:ok, _} = Imports.cancel(import_row)
assert :ok = perform_job(CloneRepo, %{"item_id" => item.id})
assert Repo.get(ImportItem, item.id).status == "skipped"
end
test "a clone failure retries first and only fails on the last attempt" do
user = user_fixture()
# Nothing serves this host, so the clone fails.
unreachable = %{
discovered("nope")
| clone_url: "https://127.0.0.1:1/acme/nope.git"
}
{:ok, import_row} = Imports.start_import(user, user, base_attrs(), [unreachable], nil)
[item] = import_row.items
assert {:error, _} =
perform_job(CloneRepo, %{"item_id" => item.id}, attempt: 1, max_attempts: 3)
assert Repo.get(ImportItem, item.id).status == "pending"
assert Imports.get_import(import_row.id).status == "running"
assert :ok = perform_job(CloneRepo, %{"item_id" => item.id}, attempt: 3, max_attempts: 3)
failed = Repo.get(ImportItem, item.id)
assert failed.status == "failed"
assert failed.error =~ "clone failed"
assert Imports.get_import(import_row.id).status == "failed"
end
end
# ── helpers ──────────────────────────────────────────────────────────
defp base_attrs, do: %{"provider" => "github", "remote_owner" => "acme"}
defp discovered(name) do
%{
name: name,
full_name: "acme/#{name}",
clone_url: "https://github.com/acme/#{name}.git",
description: nil,
default_branch: "main",
private: false,
fork: false,
archived: false
}
end
# `upstream_url` is set programmatically by the import path; poke it
# directly here so the sync tests can point at a local bare repo.
defp set_upstream(repo, path) do
repo
|> Ecto.Changeset.change(upstream_url: path)
|> Repo.update!()
end
defp seed_commit(repo) do
blob_sha = write_blob(repo.disk_path, "hello\n")
tree_sha = make_tree(repo.disk_path, nil, "README.md", blob_sha)
{:ok, commit_sha} =
Git.commit_tree(repo.disk_path, tree_sha, [], "init",
author: {"t", "t@t", DateTime.utc_now(:second)}
)
:ok = Git.update_ref(repo.disk_path, "refs/heads/main", commit_sha, nil)
commit_sha
end
defp add_commit(repo, parent_sha) do
blob = write_blob(repo.disk_path, "v2\n")
tree = make_tree(repo.disk_path, parent_sha, "README.md", blob)
{:ok, sha} =
Git.commit_tree(repo.disk_path, tree, [parent_sha], "second",
author: {"t", "t@t", DateTime.utc_now(:second)}
)
:ok = Git.update_ref(repo.disk_path, "refs/heads/main", sha, parent_sha)
sha
end
defp write_blob(path, contents) do
tmp = Path.join(System.tmp_dir!(), "import-blob-#{System.unique_integer([:positive])}")
File.write!(tmp, contents)
{hex, 0} = System.cmd("git", ["-C", path, "hash-object", "-w", tmp])
File.rm(tmp)
{:ok, sha} = Git.from_hex(String.trim(hex))
sha
end
defp make_tree(path, parent_sha, filename, blob_sha) do
index = Path.join(System.tmp_dir!(), "import-idx-#{System.unique_integer([:positive])}")
env = [{"GIT_INDEX_FILE", index}]
try do
if parent_sha do
{:ok, parent} = Git.commit(path, parent_sha)
{_, 0} =
System.cmd("git", ["-C", path, "read-tree", Git.to_hex(parent.tree_sha)], env: env)
end
{_, 0} =
System.cmd(
"git",
[
"-C",
path,
"update-index",
"--add",
"--cacheinfo",
"100644," <> Git.to_hex(blob_sha) <> "," <> filename
],
env: env
)
{hex, 0} = System.cmd("git", ["-C", path, "write-tree"], env: env)
{:ok, sha} = Git.from_hex(String.trim(hex))
sha
after
File.rm(index)
end
end
end