neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Offer a prefilled pull request from the fork-ahead banner
5a2585d · Gabriel Morell · 2026-09-09 21:47
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud_web/live/repo_live/show.ex
+16
−0
@@ -82,6 +82,15 @@ defmodule GitGudWeb.RepoLive.Show do
| 82 | 82 | end |
| 83 | 83 | end |
| 84 | 84 | |
| 85 | + # Both sides of the PR, prefilled from what the banner already knows: | |
| 86 | + # this fork's branch into the same branch on the parent, which is the | |
| 87 | + # comparison `fork_status/2` just made. The page is mounted on the | |
| 88 | + # fork, so the parent is named by id — `PrLive.New` resolves it out of | |
| 89 | + # the shared-lineage candidate pool rather than us having to load it. | |
| 90 | + defp open_pr_link(handle, repo, fork_status) do | |
| 91 | + ~p"/r/#{handle}/#{repo.name}/pulls/new?#{[source_repository_id: repo.id, source: fork_status.branch, target_repository_id: repo.parent_repository_id, target: fork_status.branch]}" | |
| 92 | + end | |
| 93 | + | |
| 85 | 94 | defp maybe_fork_status(%{parent_repository_id: nil}, _ref), do: nil |
| 86 | 95 | |
| 87 | 96 | defp maybe_fork_status(repo, ref) do |
@@ -155,6 +164,13 @@ defmodule GitGudWeb.RepoLive.Show do
| 155 | 164 | Diverged — sync isn't a fast-forward; resolve via PR or manual merge. |
| 156 | 165 | </p> |
| 157 | 166 | </div> |
| 167 | + <.link | |
| 168 | + :if={@fork_status.ahead > 0 and @current_scope != nil and @current_scope.user != nil} | |
| 169 | + navigate={open_pr_link(@handle, @repo, @fork_status)} | |
| 170 | + class="btn btn-xs btn-primary" | |
| 171 | + > | |
| 172 | + <.icon name="hero-arrow-right-circle" class="size-4" /> Open pull request | |
| 173 | + </.link> | |
| 158 | 174 | <button |
| 159 | 175 | :if={@fork_status.fast_forward?} |
| 160 | 176 | phx-click="sync_fork" |
added
test/git_gud_web/live/repo_live/fork_banner_test.exs
+200
−0
@@ -0,0 +1,200 @@
| 1 | +defmodule GitGudWeb.RepoLive.ForkBannerTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The fork-status banner on a repo page, and the prefilled | |
| 4 | + "Open pull request" link it offers once the fork is ahead. | |
| 5 | + """ | |
| 6 | + | |
| 7 | + use GitGudWeb.ConnCase, async: false | |
| 8 | + | |
| 9 | + import Phoenix.LiveViewTest | |
| 10 | + import GitGud.AccountsFixtures | |
| 11 | + import GitGud.ForgeFixtures | |
| 12 | + | |
| 13 | + alias GitGud.Git | |
| 14 | + alias GitGud.Repositories | |
| 15 | + | |
| 16 | + @moduletag :git_required | |
| 17 | + | |
| 18 | + setup do | |
| 19 | + if System.find_executable("git") == nil do | |
| 20 | + {:skip, "git not on PATH"} | |
| 21 | + else | |
| 22 | + :ok | |
| 23 | + end | |
| 24 | + end | |
| 25 | + | |
| 26 | + # An upstream with one commit, forked, with the fork one commit ahead | |
| 27 | + # on main — the state the banner describes. | |
| 28 | + defp fork_ahead_by_one do | |
| 29 | + {_owner, upstream} = repository_fixture() | |
| 30 | + {:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"}) | |
| 31 | + seed_commit(upstream) | |
| 32 | + | |
| 33 | + forker = user_fixture() | |
| 34 | + {:ok, fork} = Repositories.fork(upstream, forker, forker) | |
| 35 | + {:ok, fork} = Repositories.update_repository(fork, %{"visibility" => "public"}) | |
| 36 | + | |
| 37 | + advance_main(fork) | |
| 38 | + | |
| 39 | + {forker, upstream, fork} | |
| 40 | + end | |
| 41 | + | |
| 42 | + defp fork_path(fork) do | |
| 43 | + handle = Repositories.Storage.repo_handle(GitGud.Repo.preload(fork, [:owner, :organization])) | |
| 44 | + ~p"/r/#{handle}/#{fork.name}" | |
| 45 | + end | |
| 46 | + | |
| 47 | + test "an ahead fork offers a pull request link with both sides filled in", %{conn: conn} do | |
| 48 | + {forker, upstream, fork} = fork_ahead_by_one() | |
| 49 | + | |
| 50 | + {:ok, _lv, html} = live(log_in_user(conn, forker), fork_path(fork)) | |
| 51 | + | |
| 52 | + assert html =~ "commit(s) ahead" | |
| 53 | + assert html =~ "Open pull request" | |
| 54 | + | |
| 55 | + # Source is this fork's branch, target the same branch on the parent. | |
| 56 | + assert html =~ "source_repository_id=#{fork.id}" | |
| 57 | + assert html =~ "target_repository_id=#{upstream.id}" | |
| 58 | + assert html =~ "source=main" | |
| 59 | + assert html =~ "target=main" | |
| 60 | + end | |
| 61 | + | |
| 62 | + test "the link lands on a new-PR form already pointed at the upstream", %{conn: conn} do | |
| 63 | + {forker, upstream, fork} = fork_ahead_by_one() | |
| 64 | + | |
| 65 | + {:ok, lv, _html} = live(log_in_user(conn, forker), fork_path(fork)) | |
| 66 | + | |
| 67 | + {:ok, pr_lv, html} = | |
| 68 | + lv | |
| 69 | + |> element("a", "Open pull request") | |
| 70 | + |> render_click() | |
| 71 | + |> follow_redirect(log_in_user(conn, forker)) | |
| 72 | + | |
| 73 | + assert html =~ "New pull request" | |
| 74 | + | |
| 75 | + # Both dropdowns land pre-selected — the base on the upstream, not | |
| 76 | + # back on the fork. Asserting on `selected` rather than the option's | |
| 77 | + # presence, since every candidate renders as an option either way. | |
| 78 | + assert has_element?( | |
| 79 | + pr_lv, | |
| 80 | + ~s{select[name="pull_request[repository_id]"] option[value="#{upstream.id}"][selected]} | |
| 81 | + ) | |
| 82 | + | |
| 83 | + assert has_element?( | |
| 84 | + pr_lv, | |
| 85 | + ~s{select[name="pull_request[source_repository_id]"] option[value="#{fork.id}"][selected]} | |
| 86 | + ) | |
| 87 | + | |
| 88 | + assert has_element?( | |
| 89 | + pr_lv, | |
| 90 | + ~s{select[name="pull_request[source_ref]"] option[value="main"][selected]} | |
| 91 | + ) | |
| 92 | + | |
| 93 | + assert has_element?( | |
| 94 | + pr_lv, | |
| 95 | + ~s{select[name="pull_request[target_ref]"] option[value="main"][selected]} | |
| 96 | + ) | |
| 97 | + end | |
| 98 | + | |
| 99 | + test "anonymous visitors see the banner but no link", %{conn: conn} do | |
| 100 | + {_forker, _upstream, fork} = fork_ahead_by_one() | |
| 101 | + | |
| 102 | + {:ok, _lv, html} = live(conn, fork_path(fork)) | |
| 103 | + | |
| 104 | + assert html =~ "commit(s) ahead" | |
| 105 | + refute html =~ "Open pull request" | |
| 106 | + end | |
| 107 | + | |
| 108 | + test "a fork that is only behind gets Sync, not a PR link", %{conn: conn} do | |
| 109 | + {_owner, upstream} = repository_fixture() | |
| 110 | + {:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"}) | |
| 111 | + seed_commit(upstream) | |
| 112 | + | |
| 113 | + forker = user_fixture() | |
| 114 | + {:ok, fork} = Repositories.fork(upstream, forker, forker) | |
| 115 | + {:ok, fork} = Repositories.update_repository(fork, %{"visibility" => "public"}) | |
| 116 | + | |
| 117 | + # Upstream moves on; the fork stays put. | |
| 118 | + advance_main(upstream) | |
| 119 | + | |
| 120 | + {:ok, _lv, html} = live(log_in_user(conn, forker), fork_path(fork)) | |
| 121 | + | |
| 122 | + assert html =~ "commit(s) behind upstream" | |
| 123 | + assert html =~ "Sync from upstream" | |
| 124 | + refute html =~ "Open pull request" | |
| 125 | + end | |
| 126 | + | |
| 127 | + # ── git plumbing ───────────────────────────────────────────────────── | |
| 128 | + | |
| 129 | + defp seed_commit(repo) do | |
| 130 | + blob_sha = write_blob(repo.disk_path, "hello\n") | |
| 131 | + tree_sha = make_tree(repo.disk_path, nil, "README.md", blob_sha) | |
| 132 | + | |
| 133 | + {:ok, commit_sha} = | |
| 134 | + Git.commit_tree(repo.disk_path, tree_sha, [], "init", | |
| 135 | + author: {"t", "t@t", DateTime.utc_now(:second)} | |
| 136 | + ) | |
| 137 | + | |
| 138 | + :ok = Git.update_ref(repo.disk_path, "refs/heads/main", commit_sha, nil) | |
| 139 | + commit_sha | |
| 140 | + end | |
| 141 | + | |
| 142 | + defp advance_main(repo) do | |
| 143 | + {:ok, main} = Git.resolve(repo.disk_path, "main") | |
| 144 | + blob = write_blob(repo.disk_path, "more\n") | |
| 145 | + tree = make_tree(repo.disk_path, main, "more.txt", blob) | |
| 146 | + | |
| 147 | + {:ok, new_sha} = | |
| 148 | + Git.commit_tree(repo.disk_path, tree, [main], "advance", | |
| 149 | + author: {"t", "t@t", DateTime.utc_now(:second)} | |
| 150 | + ) | |
| 151 | + | |
| 152 | + :ok = Git.update_ref(repo.disk_path, "refs/heads/main", new_sha, main) | |
| 153 | + new_sha | |
| 154 | + end | |
| 155 | + | |
| 156 | + defp write_blob(path, contents) do | |
| 157 | + tmp = Path.join(System.tmp_dir!(), "fb-blob-#{System.unique_integer([:positive])}") | |
| 158 | + File.write!(tmp, contents) | |
| 159 | + {hex, 0} = System.cmd("git", ["-C", path, "hash-object", "-w", tmp]) | |
| 160 | + File.rm(tmp) | |
| 161 | + {:ok, sha} = Git.from_hex(String.trim(hex)) | |
| 162 | + sha | |
| 163 | + end | |
| 164 | + | |
| 165 | + defp make_tree(path, parent_sha, filename, blob_sha) do | |
| 166 | + index = Path.join(System.tmp_dir!(), "fb-idx-#{System.unique_integer([:positive])}") | |
| 167 | + env = [{"GIT_INDEX_FILE", index}] | |
| 168 | + | |
| 169 | + try do | |
| 170 | + if parent_sha do | |
| 171 | + {:ok, parent_commit} = Git.commit(path, parent_sha) | |
| 172 | + | |
| 173 | + {_, 0} = | |
| 174 | + System.cmd("git", ["-C", path, "read-tree", Git.to_hex(parent_commit.tree_sha)], | |
| 175 | + env: env | |
| 176 | + ) | |
| 177 | + end | |
| 178 | + | |
| 179 | + {_, 0} = | |
| 180 | + System.cmd( | |
| 181 | + "git", | |
| 182 | + [ | |
| 183 | + "-C", | |
| 184 | + path, | |
| 185 | + "update-index", | |
| 186 | + "--add", | |
| 187 | + "--cacheinfo", | |
| 188 | + "100644," <> Git.to_hex(blob_sha) <> "," <> filename | |
| 189 | + ], | |
| 190 | + env: env | |
| 191 | + ) | |
| 192 | + | |
| 193 | + {hex, 0} = System.cmd("git", ["-C", path, "write-tree"], env: env) | |
| 194 | + {:ok, tree_sha} = Git.from_hex(String.trim(hex)) | |
| 195 | + tree_sha | |
| 196 | + after | |
| 197 | + File.rm(index) | |
| 198 | + end | |
| 199 | + end | |
| 200 | +end |
Parents: 8a2b95a