Open pull requests between any two repos sharing lineage

8753207 · Gabriel Morell · 2026-09-09 20:51

13 files +538 -83
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/git.ex
+8 −0
@@ -68,6 +68,14 @@ defmodule GitGud.Git do
68 68 @spec log(repo_path(), sha(), keyword()) :: {:ok, [commit()]} | {:error, term()}
69 69 def log(path, start_sha, opts \\ []), do: dispatch(:log, [path, start_sha, opts])
70 70
71 + @doc """
72 + Every parentless commit reachable from any refa repo's lineage
73 + fingerprint. Two repos that share one are forks of each other, however
74 + the copy was made and whether or not we recorded a link.
75 + """
76 + @spec root_commits(repo_path()) :: {:ok, [sha()]} | {:error, term()}
77 + def root_commits(path), do: dispatch(:root_commits, [path])
78 +
71 79 @doc "Load a single commit by SHA."
72 80 @spec commit(repo_path(), sha()) :: {:ok, commit()} | {:error, term()}
73 81 def commit(path, sha), do: dispatch(:commit, [path, sha])
modified lib/git_gud/git/backend.ex
+1 −0
@@ -13,6 +13,7 @@ defmodule GitGud.Git.Backend do
13 13 @callback log(Git.repo_path(), Git.sha(), keyword()) ::
14 14 {:ok, [Git.commit()]} | {:error, term()}
15 15 @callback commit(Git.repo_path(), Git.sha()) :: {:ok, Git.commit()} | {:error, term()}
16 + @callback root_commits(Git.repo_path()) :: {:ok, [Git.sha()]} | {:error, term()}
16 17 @callback tree(Git.repo_path(), Git.sha()) :: {:ok, [Git.tree_entry()]} | {:error, term()}
17 18 @callback blob_bytes(Git.repo_path(), Git.sha()) :: {:ok, binary()} | {:error, term()}
18 19 @callback blob_meta(Git.repo_path(), Git.sha()) :: {:ok, Git.blob_meta()} | {:error, term()}
modified lib/git_gud/git/cli.ex
+24 −0
@@ -98,6 +98,30 @@ defmodule GitGud.Git.Cli do
98 98 end
99 99 end
100 100
101 + @impl true
102 + def root_commits(path) do
103 + case git(path, ["rev-list", "--max-parents=0", "--all"]) do
104 + {:ok, out} ->
105 + shas =
106 + out
107 + |> String.split("\n", trim: true)
108 + |> Enum.map(&String.trim/1)
109 + |> Enum.flat_map(fn hex ->
110 + case Git.from_hex(hex) do
111 + {:ok, sha} -> [sha]
112 + _ -> []
113 + end
114 + end)
115 +
116 + {:ok, Enum.uniq(shas)}
117 +
118 + # An empty repo has no refs at all; `rev-list --all` fails rather
119 + # than returning nothing.
120 + {:error, _} ->
121 + {:ok, []}
122 + end
123 + end
124 +
101 125 @impl true
102 126 def commit(path, sha) do
103 127 case log(path, sha, limit: 1) do
modified lib/git_gud/git/nif.ex
+3 −0
@@ -37,6 +37,9 @@ defmodule GitGud.Git.Nif do
37 37 @impl true
38 38 def commit(_path, _sha), do: nif_error()
39 39
40 + @impl true
41 + def root_commits(_path), do: {:error, :unsupported}
42 +
40 43 @impl true
41 44 def tree(_path, _sha), do: nif_error()
42 45
modified lib/git_gud/pull_requests/pull_request.ex
+4 −1
@@ -77,11 +77,14 @@ defmodule GitGud.PullRequests.PullRequest do
77 77 |> validate_distinct_refs()
78 78 end
79 79
80 + # Only meaningful within one repo. Across repos `main -> main` is the
81 + # ordinary fork PR, so identical ref names there are fine.
80 82 defp validate_distinct_refs(cs) do
81 83 src = get_field(cs, :source_ref)
82 84 tgt = get_field(cs, :target_ref)
85 + same_repo? = get_field(cs, :source_repository_id) in [nil, get_field(cs, :repository_id)]
83 86
84 if is_binary(src) and src == tgt,
87 + if same_repo? and is_binary(src) and src == tgt,
85 88 do: add_error(cs, :source_ref, "must differ from target_ref"),
86 89 else: cs
87 90 end
modified lib/git_gud/repositories.ex
+95 −1

Click to load diff…

modified lib/git_gud/repositories/hook_receiver.ex
+11 −0

Click to load diff…

added lib/git_gud/repositories/repository_root.ex
+19 −0

Click to load diff…

modified lib/git_gud_web/live/pr_live/new.ex
+109 −81

Click to load diff…

added lib/mix/tasks/gitgud.backfill_roots.ex
+25 −0

Click to load diff…

added priv/repo/migrations/20260909020000_create_repository_roots.exs
+20 −0

Click to load diff…

added test/git_gud/repository_lineage_test.exs
+84 −0

Click to load diff…

added test/git_gud_web/live/pr_live/new_cross_repo_test.exs
+135 −0

Click to load diff…

Parents: eb84db3