14.5 KiB · text History b868907
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