defmodule GitGud.UpstreamReconcileTest do
@moduledoc """
Adopting an existing repository as an upstream mirror, and the
reconcile sync mode that adoption turns on.
"""
use GitGud.DataCase, async: false
use Oban.Testing, repo: GitGud.Repo
alias GitGud.Git
alias GitGud.Imports
alias GitGud.Imports.Import
alias GitGud.Imports.ImportItem
alias GitGud.Imports.Workers.CloneRepo
alias GitGud.Repositories
alias GitGud.Repositories.Workers.UpstreamSync
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 "link_upstream/3" do
test "links a local repo that shares history with the remote" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
local = sibling_of(upstream)
assert {:ok, linked} = Repositories.link_upstream(local, upstream.disk_path)
assert linked.upstream_url == upstream.disk_path
# Adoption never touches refs, so the repo keeps its own history
# until someone asks for a sync — hence reconcile, not mirror.
assert linked.upstream_sync_mode == "reconcile"
assert linked.upstream_synced_at == nil
end
test "cleans up the scratch refs it used to compare" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
local = sibling_of(upstream)
assert {:ok, _} = Repositories.link_upstream(local, upstream.disk_path)
assert refs_under(local, "refs/link-check") == []
end
test "refuses a same-name repo whose history is unrelated" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
# Same name, its own root commit: a different project that happens
# to be called the same thing.
{_user, local} = repository_fixture(%{name: upstream.name})
_ = seed_commit(local, "unrelated\n")
_ = Repositories.refresh_root_commits(local)
assert {:error, :unrelated_history} = Repositories.link_upstream(local, upstream.disk_path)
assert Repositories.get_repository!(local.id).upstream_url == nil
end
test "an empty local repo has nothing to compare against" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
{_user, local} = repository_fixture()
assert {:error, :empty_repository} = Repositories.link_upstream(local, upstream.disk_path)
end
test "relinking to the same upstream is a no-op, a different one is a conflict" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, linked} = Repositories.link_upstream(local, upstream.disk_path)
assert {:ok, ^linked} = Repositories.link_upstream(linked, upstream.disk_path)
assert {:error, {:upstream_mismatch, url}} =
Repositories.link_upstream(linked, "https://example.com/other.git")
assert url == upstream.disk_path
end
test "an unreadable remote reports the fetch failure" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
local = sibling_of(upstream)
assert {:error, {:git_fetch, out}} =
Repositories.link_upstream(local, Path.join(System.tmp_dir!(), "not-a-repo"))
assert is_binary(out)
end
end
describe "sync_upstream/3 in reconcile mode" do
test "fast-forwards what it can and leaves everything else alone" do
{_owner, upstream} = repository_fixture()
sha1 = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
# Upstream moves main on, and adds a branch we've never seen.
sha2 = add_commit(upstream, sha1)
:ok = Git.update_ref(upstream.disk_path, "refs/heads/topic", sha2, nil)
# We have a branch upstream doesn't.
:ok = Git.update_ref(local.disk_path, "refs/heads/ours", sha1, nil)
assert {:ok, %{mode: "reconcile", plan: plan, changes: changes}} =
Repositories.sync_upstream(local)
assert {:ok, ^sha2} = Git.resolve(local.disk_path, "main")
assert {:ok, ^sha2} = Git.resolve(local.disk_path, "topic")
# Never pruned: upstream not having it is not a reason to drop it.
assert {:ok, ^sha1} = Git.resolve(local.disk_path, "ours")
assert Enum.map(plan.fast_forward, & &1.branch) == ["main"]
assert Enum.map(plan.created, & &1.branch) == ["topic"]
assert Enum.map(plan.local_only, & &1.branch) == ["ours"]
assert plan.diverged == []
assert length(changes) == 2
end
test "a branch both sides moved is reported, not overwritten" do
{_owner, upstream} = repository_fixture()
sha1 = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
theirs = add_commit(upstream, sha1)
ours = add_commit(local, sha1, "ours\n")
assert {:ok, %{plan: plan}} = Repositories.sync_upstream(local)
assert [%{branch: "main", ahead: 1, behind: 1} = row] = plan.diverged
assert row.local_sha == Git.to_hex(ours)
assert row.upstream_sha == Git.to_hex(theirs)
# Ours is still ours.
assert {:ok, ^ours} = Git.resolve(local.disk_path, "main")
end
test "mirror mode still takes upstream's side" do
{_owner, upstream} = repository_fixture()
sha1 = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
theirs = add_commit(upstream, sha1)
_ours = add_commit(local, sha1, "ours\n")
{:ok, local} = Repositories.update_repository(local, %{"upstream_sync_mode" => "mirror"})
assert {:ok, %{changes: [_ | _]}} = Repositories.sync_upstream(local)
assert {:ok, ^theirs} = Git.resolve(local.disk_path, "main")
end
test "overwrite_branch_from_upstream resolves one diverged branch" do
{_owner, upstream} = repository_fixture()
sha1 = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
theirs = add_commit(upstream, sha1)
_ours = add_commit(local, sha1, "ours\n")
assert {:ok, %{plan: %{diverged: [_]}}} = Repositories.sync_upstream(local)
assert {:ok, %{branch: "main"}} = Repositories.overwrite_branch_from_upstream(local, "main")
assert {:ok, ^theirs} = Git.resolve(local.disk_path, "main")
assert {:error, :no_upstream_branch} =
Repositories.overwrite_branch_from_upstream(local, "nope")
end
test "upstream_status/2 reports without changing anything" do
{_owner, upstream} = repository_fixture()
sha1 = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
sha2 = add_commit(upstream, sha1)
assert {:ok, plan} = Repositories.upstream_status(local)
assert [%{branch: "main", behind: 1, upstream_sha: hex}] = plan.fast_forward
assert hex == Git.to_hex(sha2)
# Reported, not applied.
assert {:ok, ^sha1} = Git.resolve(local.disk_path, "main")
end
end
describe "import adoption" do
test "an item whose name is taken by the same project links instead of skipping" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
user = user_fixture()
local = sibling_of(upstream, user)
{:ok, import_row} = start_batch(user)
item = item_for(import_row, local.name, upstream.disk_path)
assert {:link, linked} = Imports.import_item(item, import_row)
assert linked.id == local.id
assert linked.upstream_url == upstream.disk_path
end
test "an item whose name is taken by a different project still skips" do
{user, local} = repository_fixture()
# (upstream below is a different project that shares the name)
_sha = seed_commit(local, "ours\n")
_ = Repositories.refresh_root_commits(local)
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream, "theirs\n")
{:ok, import_row} = start_batch(user)
item = item_for(import_row, local.name, upstream.disk_path)
assert {:skip, message} = Imports.import_item(item, import_row)
assert message =~ local.name
assert message =~ "shares no history"
end
test "the worker records a linked item and settles the batch" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
user = user_fixture()
local = sibling_of(upstream, user)
{:ok, import_row} = start_batch(user)
item = Repo.insert!(item_for(import_row, local.name, upstream.disk_path))
assert :ok = perform_job(CloneRepo, %{"item_id" => item.id})
linked = Repo.get(ImportItem, item.id)
assert linked.status == "linked"
assert linked.repository_id == local.id
assert Imports.get_import(import_row.id).status == "completed"
end
end
describe "scheduled sync" do
test "the cron job fans out one job per opted-in repository" do
{_owner, upstream} = repository_fixture()
sha1 = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
# Not opted in yet: the walk finds nothing.
assert {:ok, 0} = perform_job(UpstreamSync, %{})
{:ok, local} = Repositories.update_repository(local, %{"upstream_auto_sync" => true})
assert [%{id: id}] = Repositories.list_auto_sync_repositories()
assert id == local.id
assert {:ok, 1} = perform_job(UpstreamSync, %{})
# And the per-repo job does the sync, in the repo's own mode.
sha2 = add_commit(upstream, sha1)
assert :ok = perform_job(UpstreamSync, %{"repository_id" => local.id})
assert {:ok, ^sha2} = Git.resolve(local.disk_path, "main")
end
test "a private upstream is recorded as needing credentials, not retried" do
{_owner, upstream} = repository_fixture()
_sha = seed_commit(upstream)
local = sibling_of(upstream)
{:ok, local} = Repositories.link_upstream(local, upstream.disk_path)
# Scheduled syncs are anonymous, so point it at something that
# asks for a login and can't get one.
local =
local
|> Ecto.Changeset.change(
upstream_url: "https://127.0.0.1:1/private.git",
upstream_auto_sync: true
)
|> Repo.update!()
assert :ok = perform_job(UpstreamSync, %{"repository_id" => local.id})
error = Repositories.get_repository!(local.id).upstream_sync_error
assert is_binary(error)
# A later successful sync clears it.
{:ok, local} =
local
|> Ecto.Changeset.change(upstream_url: upstream.disk_path)
|> Repo.update()
assert :ok = perform_job(UpstreamSync, %{"repository_id" => local.id})
assert Repositories.get_repository!(local.id).upstream_sync_error == nil
end
test "credentials for a scheduled sync are anonymous for now" do
{_owner, repo} = repository_fixture()
assert UpstreamSync.credentials_for(repo) == nil
end
end
# ── helpers ──────────────────────────────────────────────────────────
# A repository cloned from `upstream` but not (yet) linked to it —
# what a hand-migrated repo looks like when the bulk import catches up
# with it later.
defp sibling_of(upstream, owner \\ nil) do
user = owner || user_fixture()
{:ok, repo} =
Repositories.create_repository(user, %{
"name" => upstream.name,
"clone_addr" => upstream.disk_path
})
repo
|> Ecto.Changeset.change(upstream_url: nil)
|> Repo.update!()
end
defp start_batch(user) do
Imports.start_import(
user,
user,
%{"provider" => "git", "remote_owner" => "https://example.com/acme/x.git"},
[],
nil
)
end
# Built as a struct, not through `changeset/2`: `clone_url` is
# validated as an http(s) URL there, and these tests clone from a path
# on disk.
defp item_for(%Import{} = import_row, name, clone_url) do
%ImportItem{
repository_import_id: import_row.id,
remote_name: name,
remote_full_name: "acme/#{name}",
target_name: name,
clone_url: clone_url,
remote_default_branch: "main"
}
end
defp refs_under(repo, prefix) do
{out, 0} =
System.cmd("git", ["-C", repo.disk_path, "for-each-ref", "--format=%(refname)", prefix])
String.split(out, "\n", trim: true)
end
defp seed_commit(repo, contents \\ "hello\n") do
blob_sha = write_blob(repo.disk_path, contents)
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, contents \\ "v2\n") do
blob = write_blob(repo.disk_path, contents)
tree = make_tree(repo.disk_path, parent_sha, "README.md", blob)
{:ok, sha} =
Git.commit_tree(repo.disk_path, tree, [parent_sha], "next: #{contents}",
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!(), "up-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!(), "up-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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
14.5 KiB · text
History
a366332