13.1 KiB · text 5af55d9
defmodule GitGud.ForksTest do
use GitGud.DataCase, async: false
alias GitGud.Git
alias GitGud.Organizations
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 "Repositories.fork/4" do
test "owner can fork their own repo into their own namespace (no-op self-fork)" do
{user, repo} = repository_fixture()
seed_commit(repo)
# Self-fork into the same namespace with a new name.
assert {:ok, fork} = Repositories.fork(repo, user, user, %{"name" => repo.name <> "-clone"})
assert fork.parent_repository_id == repo.id
assert fork.id != repo.id
assert File.dir?(fork.disk_path)
# The clone has the same HEAD as the source.
assert {:ok, src_main} = Git.resolve(repo.disk_path, "main")
assert {:ok, fork_main} = Git.resolve(fork.disk_path, "main")
assert src_main == fork_main
end
test "rejects forking a private repo you can't read" do
{owner, repo} = repository_fixture()
seed_commit(repo)
assert repo.visibility == "private"
outsider = user_fixture()
assert {:error, :source_forbidden} = Repositories.fork(repo, outsider, outsider)
_ = owner
end
test "any user can fork a public repo into their own namespace" do
{_owner, repo} = repository_fixture()
{:ok, repo} = Repositories.update_repository(repo, %{"visibility" => "public"})
seed_commit(repo)
forker = user_fixture()
assert {:ok, fork} = Repositories.fork(repo, forker, forker)
assert fork.owner_id == forker.id
assert fork.parent_repository_id == repo.id
end
test "forking into an org requires admin in the org" do
{_owner, repo} = repository_fixture()
{:ok, repo} = Repositories.update_repository(repo, %{"visibility" => "public"})
seed_commit(repo)
admin = user_fixture()
{:ok, org} = Organizations.create_organization(admin, %{"handle" => "forkz"})
assert {:ok, fork} = Repositories.fork(repo, org, admin)
assert fork.organization_id == org.id
outsider = user_fixture()
assert {:error, :dest_forbidden} = Repositories.fork(repo, org, outsider)
end
test "list_forks/1 returns direct children only" do
{user, repo} = repository_fixture()
{:ok, repo} = Repositories.update_repository(repo, %{"visibility" => "public"})
seed_commit(repo)
forker = user_fixture()
{:ok, fork} = Repositories.fork(repo, forker, forker)
seed_commit(fork)
# Fork of a fork — parent is `fork`, not `repo`.
grandchild_owner = user_fixture()
{:ok, _} = Repositories.update_repository(fork, %{"visibility" => "public"})
{:ok, _grand} = Repositories.fork(fork, grandchild_owner, grandchild_owner)
forks = Repositories.list_forks(repo)
assert length(forks) == 1
assert hd(forks).id == fork.id
_ = user
end
end
describe "fork_status + sync_fork" do
test "behind/ahead counts a fresh fork as up-to-date" do
{_owner, repo} = repository_fixture()
{:ok, repo} = Repositories.update_repository(repo, %{"visibility" => "public"})
seed_commit(repo)
forker = user_fixture()
{:ok, fork} = Repositories.fork(repo, forker, forker)
assert {:ok, %{behind: 0, ahead: 0, fast_forward?: false}} =
Repositories.fork_status(fork, "main")
end
test "fast-forward sync brings the fork up to upstream" do
{_owner, upstream} = repository_fixture()
{:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
seed_commit(upstream)
forker = user_fixture()
{:ok, fork} = Repositories.fork(upstream, forker, forker)
# Upstream advances; fork stays put.
seed_two_commits_on_main(upstream)
assert {:ok, %{behind: behind, ahead: 0, fast_forward?: true}} =
Repositories.fork_status(fork, "main")
assert behind >= 1
assert {:ok, %{behind: ^behind, from: _, to: _}} =
Repositories.sync_fork(fork, "main", forker)
assert {:ok, %{behind: 0, ahead: 0}} = Repositories.fork_status(fork, "main")
assert {:ok, fork_sha} = Git.resolve(fork.disk_path, "main")
assert {:ok, up_sha} = Git.resolve(upstream.disk_path, "main")
assert fork_sha == up_sha
end
test "diverged sync refuses without :force" do
{_owner, upstream} = repository_fixture()
{:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
seed_commit(upstream)
forker = user_fixture()
{:ok, fork} = Repositories.fork(upstream, forker, forker)
{:ok, fork_main} = Git.resolve(fork.disk_path, "main")
fork_blob = write_blob(fork.disk_path, "fork-side\n")
fork_tree = make_tree(fork.disk_path, fork_main, "fork.txt", fork_blob)
{:ok, fork_new} =
Git.commit_tree(fork.disk_path, fork_tree, [fork_main], "fork advance",
author: {"f", "f@f", DateTime.utc_now(:second)}
)
:ok = Git.update_ref(fork.disk_path, "refs/heads/main", fork_new, fork_main)
seed_two_commits_on_main(upstream)
assert {:ok, %{ahead: 1}} = Repositories.fork_status(fork, "main")
assert {:error, {:diverged, _}} = Repositories.sync_fork(fork, "main", forker)
end
test "non-owner cannot sync someone else's fork" do
{_owner, upstream} = repository_fixture()
{:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
seed_commit(upstream)
forker = user_fixture()
{:ok, fork} = Repositories.fork(upstream, forker, forker)
seed_two_commits_on_main(upstream)
stranger = user_fixture()
assert {:error, :forbidden} = Repositories.sync_fork(fork, "main", stranger)
end
test "sync_fork on a non-fork repo errors cleanly" do
{user, repo} = repository_fixture()
seed_commit(repo)
assert {:error, :not_a_fork} = Repositories.sync_fork(repo, "main", user)
end
end
describe "PR head refresh on fork push" do
test "pushing to the fork's PR branch updates refs/pull/N/head on upstream" do
{_upstream_user, upstream} = repository_fixture()
{:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
seed_two_commits_on_main(upstream)
forker = user_fixture()
{:ok, fork} = Repositories.fork(upstream, forker, forker)
add_feature_branch(fork)
{:ok, pr} =
GitGud.PullRequests.create_pull_request(upstream, forker, %{
"title" => "fork push",
"source_ref" => "feature",
"target_ref" => "main",
"source_repository_id" => fork.id
})
initial_head = pr.head_sha
# Simulate the fork's "feature" branch advancing by one commit.
{:ok, current} = Git.resolve(fork.disk_path, "feature")
blob = write_blob(fork.disk_path, "newer\n")
tree = make_tree(fork.disk_path, current, "feature.txt", blob)
{:ok, new_sha} =
Git.commit_tree(fork.disk_path, tree, [current], "v2",
author: {"f", "f@f", DateTime.utc_now(:second)}
)
:ok = Git.update_ref(fork.disk_path, "refs/heads/feature", new_sha, current)
# CommitBackfill calls this; tests can too.
:ok = GitGud.PullRequests.refresh_cross_repo_heads(fork, "feature", new_sha)
# PR's head_sha bumped; `refs/pull/<N>/head` on upstream now
# points at the new commit.
refreshed = GitGud.Repo.get!(GitGud.PullRequests.PullRequest, pr.id)
assert refreshed.head_sha == new_sha
refute refreshed.head_sha == initial_head
assert {:ok, ref_sha} = Git.resolve(upstream.disk_path, "refs/pull/#{pr.number}/head")
assert ref_sha == new_sha
end
end
describe "Repositories.compare/5" do
test "same-repo: counts head-only commits and lists them" do
{user, repo} = repository_fixture()
_ = user
sha1 = seed_commit(repo)
:ok = Git.update_ref(repo.disk_path, "refs/heads/main", sha1, nil)
# Branch off and add a commit
blob = write_blob(repo.disk_path, "branch\n")
tree = make_tree(repo.disk_path, sha1, "b.txt", blob)
{:ok, branch_sha} =
Git.commit_tree(repo.disk_path, tree, [sha1], "branch commit",
author: {"t", "t@t", DateTime.utc_now(:second)}
)
:ok = Git.update_ref(repo.disk_path, "refs/heads/feature", branch_sha, nil)
assert {:ok, cmp} = Repositories.compare(repo, "main", repo, "feature")
assert cmp.ahead == 1
assert cmp.behind == 0
assert cmp.diff_stat.files_changed == 1
assert length(cmp.commits) == 2
end
test "cross-repo: fetches the head branch from the fork first" do
{_owner, upstream} = repository_fixture()
{:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
seed_commit(upstream)
forker = user_fixture()
{:ok, fork} = Repositories.fork(upstream, forker, forker)
add_feature_branch(fork)
assert {:ok, cmp} = Repositories.compare(upstream, "main", fork, "feature")
assert cmp.ahead == 1
assert cmp.behind == 0
end
end
describe "cross-repo PR via fork" do
test "create + mergeable + merge against a fork's branch" do
{upstream_user, upstream} = repository_fixture()
{:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
seed_two_commits_on_main(upstream)
forker = user_fixture()
{:ok, fork} = Repositories.fork(upstream, forker, forker)
# Add a `feature` branch on the fork that's a descendant of main.
add_feature_branch(fork)
assert {:ok, pr} =
GitGud.PullRequests.create_pull_request(upstream, forker, %{
"title" => "from fork",
"source_ref" => "feature",
"target_ref" => "main",
"source_repository_id" => fork.id
})
assert pr.source_repository_id == fork.id
assert pr.repository_id == upstream.id
# The cross-repo plumbing pinned the fork's head into a
# `refs/pull/<N>/head` on the upstream.
assert {:ok, pinned} = Git.resolve(upstream.disk_path, "refs/pull/#{pr.number}/head")
assert pinned == pr.head_sha
# And mergeability + merge work as in same-repo PRs.
assert {:ok, true} = GitGud.PullRequests.check_mergeability(pr)
assert {:ok, merged} = GitGud.PullRequests.merge!(pr, upstream_user)
assert merged.state == "merged"
end
end
# ── helpers ──────────────────────────────────────────────────────────
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 seed_two_commits_on_main(repo) do
sha1 = seed_commit(repo)
blob = write_blob(repo.disk_path, "v2\n")
tree = make_tree(repo.disk_path, sha1, "README.md", blob)
{:ok, sha2} =
Git.commit_tree(repo.disk_path, tree, [sha1], "second",
author: {"t", "t@t", DateTime.utc_now(:second)}
)
:ok = Git.update_ref(repo.disk_path, "refs/heads/main", sha2, sha1)
sha2
end
defp add_feature_branch(repo) do
{:ok, main} = Git.resolve(repo.disk_path, "main")
blob = write_blob(repo.disk_path, "feature\n")
tree = make_tree(repo.disk_path, main, "feature.txt", blob)
{:ok, fsha} =
Git.commit_tree(repo.disk_path, tree, [main], "feature commit",
author: {"f", "f@f", DateTime.utc_now(:second)}
)
:ok = Git.update_ref(repo.disk_path, "refs/heads/feature", fsha, nil)
fsha
end
defp write_blob(path, contents) do
tmp = Path.join(System.tmp_dir!(), "fork-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!(), "fork-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