neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Branches the forge moves itself get the push a push would
5e43417 · Gabriel Morell · 2026-10-18 18:18
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−1
@@ -18,7 +18,7 @@ reinvent them.
| 18 | 18 | - **Git server** |
| 19 | 19 | - Smart-HTTP at `/<owner>/<name>.git/*` (anonymous read on public repos, basic-auth elsewhere) |
| 20 | 20 | - **Embedded SSH daemon** with DB-backed key auth (no system `git` user, no `authorized_keys` file) |
| 21 | − - Server-side post-receive hook fans out to commit-backfill workers | |
| 21 | + - Server-side post-receive hook fans out to commit-backfill workers; branches the forge moves itself (pull request merges, applied suggestions, resolved conflicts, fork syncs) go through the same workers, so they get the `push` run, webhook and caches a push would | |
| 22 | 22 | - **Git LFS** (Batch API) over pluggable object storage |
| 23 | 23 | - **Repo browsing** |
| 24 | 24 | - Tree, blob, commit, log views with deep linking and a **shared repo header** on every subpage (title + Issues/Labels/Forks/Compare/Packages/Actions/Settings strip + Fork button) |
modified
lib/git_gud/merges.ex
+5
−3
@@ -135,7 +135,7 @@ defmodule GitGud.Merges do
| 135 | 135 | message, |
| 136 | 136 | author: author |
| 137 | 137 | ), |
| 138 | − :ok <- move_ref(repo, ref, commit, expected_old) do | |
| 138 | + :ok <- move_ref(repo, ref, commit, expected_old, Keyword.get(opts, :actor_id)) do | |
| 139 | 139 | {:ok, %{commit: commit, tree: tree}} |
| 140 | 140 | end |
| 141 | 141 | end |
@@ -213,12 +213,14 @@ defmodule GitGud.Merges do
| 213 | 213 | defp default_message(%{theirs_sha: theirs}), |
| 214 | 214 | do: "Merge #{Git.short(theirs)}, resolving conflicts" |
| 215 | 215 | |
| 216 | − defp move_ref(%Repository{disk_path: path} = repo, ref, commit, expected_old) do | |
| 216 | + defp move_ref(%Repository{disk_path: path} = repo, ref, commit, expected_old, actor_id) do | |
| 217 | 217 | case Git.update_ref(path, ref, commit, expected_old) do |
| 218 | 218 | :ok -> |
| 219 | 219 | # Keep the ref cache in step without waiting for post-receive, |
| 220 | − # the way the fork sync and PR merge paths do. | |
| 220 | + # the way the fork sync and PR merge paths do, and play the push | |
| 221 | + # the hook never sees (workflows, webhooks, caches). | |
| 221 | 222 | :ok = Repositories.apply_ref_update(repo, ref, expected_old, commit) |
| 223 | + :ok = Repositories.enqueue_ref_update(repo, ref, expected_old, commit, actor_id) | |
| 222 | 224 | :ok |
| 223 | 225 | |
| 224 | 226 | {:error, _} -> |
modified
lib/git_gud/pull_requests.ex
+28
−6
@@ -741,10 +741,17 @@ defmodule GitGud.PullRequests do
| 741 | 741 | commit_sha |
| 742 | 742 | ) |
| 743 | 743 | |
| 744 | − # A merge moves the target without the post-receive hook, so | |
| 745 | − # nothing else re-reads the default branch's `on: schedule`. | |
| 746 | − if pr.target_ref == repo.default_branch, | |
| 747 | − do: {:ok, _count} = GitGud.Workflows.Schedules.sync(repo) | |
| 744 | + # A merge moves the target without the post-receive hook, so the | |
| 745 | + # forge plays the push itself: the merge commit's `push` run, | |
| 746 | + # webhook, caches, and the default branch's `on: schedule`. | |
| 747 | + :ok = | |
| 748 | + Repositories.enqueue_ref_update( | |
| 749 | + repo, | |
| 750 | + "refs/heads/" <> pr.target_ref, | |
| 751 | + current_target_sha, | |
| 752 | + commit_sha, | |
| 753 | + actor.id | |
| 754 | + ) | |
| 748 | 755 | |
| 749 | 756 | # `closed`, with `merged: true` in the payload, as on GitHub. |
| 750 | 757 | :ok = PullRequestDispatch.enqueue(updated, "closed", actor.id) |
@@ -985,8 +992,23 @@ defmodule GitGud.PullRequests do
| 985 | 992 | :ok <- |
| 986 | 993 | Git.update_ref(path, "refs/heads/" <> pr.source_ref, commit_sha, pr.head_sha) do |
| 987 | 994 | # Keep the PR row pointing at the commit we just made, the same |
| 988 | − # way a push would. | |
| 989 | − _ = pr |> Ecto.Changeset.change(head_sha: commit_sha, mergeable: nil) |> Repo.update() | |
| 995 | + # way a push would — and, since this is a push the hook never sees, | |
| 996 | + # play it: the branch's `push` run and the PR's `synchronize` | |
| 997 | + # (which the push worker won't send, finding the head already | |
| 998 | + # moved). | |
| 999 | + {:ok, updated} = | |
| 1000 | + pr |> Ecto.Changeset.change(head_sha: commit_sha, mergeable: nil) |> Repo.update() | |
| 1001 | + | |
| 1002 | + :ok = | |
| 1003 | + Repositories.enqueue_ref_update( | |
| 1004 | + repo, | |
| 1005 | + "refs/heads/" <> pr.source_ref, | |
| 1006 | + pr.head_sha, | |
| 1007 | + commit_sha, | |
| 1008 | + actor.id | |
| 1009 | + ) | |
| 1010 | + | |
| 1011 | + :ok = PullRequestDispatch.enqueue(updated, "synchronize", actor.id) | |
| 990 | 1012 | {:ok, commit_sha} |
| 991 | 1013 | end |
| 992 | 1014 | end |
modified
lib/git_gud/repositories.ex
+38
−4
@@ -1039,7 +1039,7 @@ defmodule GitGud.Repositories do
| 1039 | 1039 | |
| 1040 | 1040 | true -> |
| 1041 | 1041 | with {:ok, status} <- fork_status(fork, branch) do |
| 1042 | − do_sync_fork(fork, branch, status, Keyword.get(opts, :force, false)) | |
| 1042 | + do_sync_fork(fork, branch, status, Keyword.get(opts, :force, false), actor) | |
| 1043 | 1043 | end |
| 1044 | 1044 | end |
| 1045 | 1045 | end |
@@ -1119,13 +1119,13 @@ defmodule GitGud.Repositories do
| 1119 | 1119 | end |
| 1120 | 1120 | end |
| 1121 | 1121 | |
| 1122 | − defp do_sync_fork(_fork, _branch, %{behind: 0}, _force), | |
| 1122 | + defp do_sync_fork(_fork, _branch, %{behind: 0}, _force, _actor), | |
| 1123 | 1123 | do: {:error, :already_up_to_date} |
| 1124 | 1124 | |
| 1125 | − defp do_sync_fork(_fork, _branch, %{ahead: ahead} = status, false) when ahead > 0, | |
| 1125 | + defp do_sync_fork(_fork, _branch, %{ahead: ahead} = status, false, _actor) when ahead > 0, | |
| 1126 | 1126 | do: {:error, {:diverged, status}} |
| 1127 | 1127 | |
| 1128 | − defp do_sync_fork(fork, branch, status, _force) do | |
| 1128 | + defp do_sync_fork(fork, branch, status, _force, actor) do | |
| 1129 | 1129 | case Git.update_ref( |
| 1130 | 1130 | fork.disk_path, |
| 1131 | 1131 | "refs/heads/" <> branch, |
@@ -1137,6 +1137,16 @@ defmodule GitGud.Repositories do
| 1137 | 1137 | :ok = |
| 1138 | 1138 | apply_ref_update(fork, "refs/heads/" <> branch, status.fork_sha, status.parent_sha) |
| 1139 | 1139 | |
| 1140 | + # The hook never sees this move: play the push it would have. | |
| 1141 | + :ok = | |
| 1142 | + enqueue_ref_update( | |
| 1143 | + fork, | |
| 1144 | + "refs/heads/" <> branch, | |
| 1145 | + status.fork_sha, | |
| 1146 | + status.parent_sha, | |
| 1147 | + actor.id | |
| 1148 | + ) | |
| 1149 | + | |
| 1140 | 1150 | {:ok, %{from: status.fork_sha, to: status.parent_sha, behind: status.behind}} |
| 1141 | 1151 | |
| 1142 | 1152 | err -> |
@@ -1849,6 +1859,30 @@ defmodule GitGud.Repositories do
| 1849 | 1859 | defp zero_sha?(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>), do: true |
| 1850 | 1860 | defp zero_sha?(_), do: false |
| 1851 | 1861 | |
| 1862 | + @doc """ | |
| 1863 | + Everything a push of `ref` from `old_sha` to `new_sha` sets off, for a | |
| 1864 | + ref the forge moved itself (a pull request merge, an applied | |
| 1865 | + suggestion, a resolved conflict, a fork sync) — which the post-receive | |
| 1866 | + hook never sees. Enqueues the same `CommitBackfill` job the hook does: | |
| 1867 | + commit caches, the `push` webhook, `push` / `create` workflows, other | |
| 1868 | + pull requests' heads, federation. `pusher_id` is who moved it. | |
| 1869 | + """ | |
| 1870 | + def enqueue_ref_update(%Repository{id: rid}, ref, old_sha, new_sha, pusher_id) do | |
| 1871 | + {:ok, _job} = | |
| 1872 | + %{ | |
| 1873 | + repository_id: rid, | |
| 1874 | + ref: ref, | |
| 1875 | + old_sha_hex: old_sha && Git.to_hex(old_sha), | |
| 1876 | + new_sha_hex: Git.to_hex(new_sha), | |
| 1877 | + pusher_id: pusher_id, | |
| 1878 | + job_token: false | |
| 1879 | + } | |
| 1880 | + |> GitGud.Repositories.Workers.CommitBackfill.new_job() | |
| 1881 | + |> Oban.insert() | |
| 1882 | + | |
| 1883 | + :ok | |
| 1884 | + end | |
| 1885 | + | |
| 1852 | 1886 | # ── commits / trees / blobs (cache-first) ──────────────────────────── |
| 1853 | 1887 | |
| 1854 | 1888 | @doc """ |
modified
lib/git_gud_web/live/repo_live/merge.ex
+2
−1
@@ -166,7 +166,8 @@ defmodule GitGudWeb.RepoLive.Merge do
| 166 | 166 | [ |
| 167 | 167 | ref: "refs/heads/" <> into, |
| 168 | 168 | message: blank_to_nil(socket.assigns.message), |
| 169 | − author: author(socket) | |
| 169 | + author: author(socket), | |
| 170 | + actor_id: socket.assigns.current_scope && socket.assigns.current_scope.user.id | |
| 170 | 171 | ] |
| 171 | 172 | |> maybe_put_pr(socket.assigns.pr) |
| 172 | 173 |
modified
test/git_gud/forks_test.exs
+12
−0
@@ -1,5 +1,6 @@
| 1 | 1 | defmodule GitGud.ForksTest do |
| 2 | 2 | use GitGud.DataCase, async: false |
| 3 | + use Oban.Testing, repo: GitGud.Repo | |
| 3 | 4 | |
| 4 | 5 | alias GitGud.Git |
| 5 | 6 | alias GitGud.Organizations |
@@ -130,6 +131,17 @@ defmodule GitGud.ForksTest do
| 130 | 131 | assert {:ok, fork_sha} = Git.resolve(fork.disk_path, "main") |
| 131 | 132 | assert {:ok, up_sha} = Git.resolve(upstream.disk_path, "main") |
| 132 | 133 | assert fork_sha == up_sha |
| 134 | + | |
| 135 | + # The hook never sees a sync: the forge plays the push itself. | |
| 136 | + assert_enqueued( | |
| 137 | + worker: GitGud.Repositories.Workers.CommitBackfill, | |
| 138 | + args: %{ | |
| 139 | + repository_id: fork.id, | |
| 140 | + ref: "refs/heads/main", | |
| 141 | + new_sha_hex: Git.to_hex(fork_sha), | |
| 142 | + pusher_id: forker.id | |
| 143 | + } | |
| 144 | + ) | |
| 133 | 145 | end |
| 134 | 146 | |
| 135 | 147 | test "diverged sync refuses without :force" do |
modified
test/git_gud/merges_test.exs
+14
−0
@@ -4,6 +4,7 @@ defmodule GitGud.MergesTest do
| 4 | 4 | sides of it, and git's own marked-up merge to open. |
| 5 | 5 | """ |
| 6 | 6 | use GitGud.DataCase, async: false |
| 7 | + use Oban.Testing, repo: GitGud.Repo | |
| 7 | 8 | |
| 8 | 9 | alias GitGud.Git |
| 9 | 10 | alias GitGud.Merges |
@@ -194,6 +195,19 @@ defmodule GitGud.MergesTest do
| 194 | 195 | assert {:ok, ^commit} = Git.resolve(ctx.repo.disk_path, "refs/heads/target") |
| 195 | 196 | end |
| 196 | 197 | |
| 198 | + test "plays the push of the resolved merge, by whoever resolved it", ctx do | |
| 199 | + assert {:ok, %{commit: commit}} = | |
| 200 | + Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => :ours}, | |
| 201 | + ref: "refs/heads/target", | |
| 202 | + actor_id: 42 | |
| 203 | + ) | |
| 204 | + | |
| 205 | + assert_enqueued( | |
| 206 | + worker: GitGud.Repositories.Workers.CommitBackfill, | |
| 207 | + args: %{ref: "refs/heads/target", new_sha_hex: Git.to_hex(commit), pusher_id: 42} | |
| 208 | + ) | |
| 209 | + end | |
| 210 | + | |
| 197 | 211 | test "taking one side verbatim", ctx do |
| 198 | 212 | assert {:ok, %{commit: commit}} = |
| 199 | 213 | Merges.resolve(ctx.repo, ctx.analysis, %{"f.txt" => :theirs}, |
modified
test/git_gud/suggestion_test.exs
+19
−0
@@ -1,5 +1,6 @@
| 1 | 1 | defmodule GitGud.SuggestionTest do |
| 2 | 2 | use GitGud.DataCase, async: false |
| 3 | + use Oban.Testing, repo: GitGud.Repo | |
| 3 | 4 | |
| 4 | 5 | import GitGud.AccountsFixtures |
| 5 | 6 | import GitGud.ForgeFixtures |
@@ -108,6 +109,24 @@ defmodule GitGud.SuggestionTest do
| 108 | 109 | assert PullRequests.get_pull_request!(repo, pr.number).head_sha == new_sha |
| 109 | 110 | end |
| 110 | 111 | |
| 112 | + test "plays the push the hook never sees, and the PR's synchronize", %{ | |
| 113 | + user: user, | |
| 114 | + pr: pr | |
| 115 | + } do | |
| 116 | + comment = suggest!(pr, user, 1, "```suggestion\npushed\n```") | |
| 117 | + {:ok, new_sha} = PullRequests.apply_suggestion(comment, user) | |
| 118 | + | |
| 119 | + assert_enqueued( | |
| 120 | + worker: GitGud.Repositories.Workers.CommitBackfill, | |
| 121 | + args: %{ref: "refs/heads/feature", new_sha_hex: Git.to_hex(new_sha), pusher_id: user.id} | |
| 122 | + ) | |
| 123 | + | |
| 124 | + assert_enqueued( | |
| 125 | + worker: GitGud.Workflows.Workers.PullRequestDispatch, | |
| 126 | + args: %{action: "synchronize"} | |
| 127 | + ) | |
| 128 | + end | |
| 129 | + | |
| 111 | 130 | test "someone without write access is refused", %{user: user, pr: pr} do |
| 112 | 131 | comment = suggest!(pr, user, 1, "```suggestion\nnope\n```") |
| 113 | 132 | outsider = user_fixture() |
added
test/git_gud/workflows/merge_push_test.exs
+80
−0
@@ -0,0 +1,80 @@
| 1 | +defmodule GitGud.Workflows.MergePushTest do | |
| 2 | + @moduledoc """ | |
| 3 | + A merge moves the target branch without the post-receive hook, so the | |
| 4 | + forge plays the push itself: the merge commit gets its `push` run (and | |
| 5 | + webhook, caches, schedules) as if someone had pushed it. | |
| 6 | + """ | |
| 7 | + use GitGud.DataCase, async: false | |
| 8 | + use Oban.Testing, repo: GitGud.Repo | |
| 9 | + | |
| 10 | + alias GitGud.PullRequests | |
| 11 | + alias GitGud.Repositories.Workers.CommitBackfill | |
| 12 | + alias GitGud.Workflows.WorkflowRun | |
| 13 | + | |
| 14 | + import GitGud.ForgeFixtures | |
| 15 | + import GitGud.WorkflowGitHelpers | |
| 16 | + | |
| 17 | + @moduletag :capture_log | |
| 18 | + | |
| 19 | + @workflow """ | |
| 20 | + name: ci | |
| 21 | + on: | |
| 22 | + push: | |
| 23 | + branches: [main] | |
| 24 | + jobs: | |
| 25 | + build: | |
| 26 | + runs-on: self-hosted | |
| 27 | + steps: | |
| 28 | + - run: echo hello | |
| 29 | + """ | |
| 30 | + | |
| 31 | + setup do | |
| 32 | + {user, repo} = repository_fixture() | |
| 33 | + main = commit!(repo, %{"README" => "hi\n"}, []) | |
| 34 | + git!(repo.disk_path, ["update-ref", "refs/heads/main", main]) | |
| 35 | + | |
| 36 | + feature = | |
| 37 | + commit!(repo, %{"README" => "hi\n", ".forgejo/workflows/ci.yml" => @workflow}, [main]) | |
| 38 | + | |
| 39 | + git!(repo.disk_path, ["update-ref", "refs/heads/feature", feature]) | |
| 40 | + | |
| 41 | + {:ok, pr} = PullRequests.create_pull_request(repo, user, pr_attrs("feature", "main")) | |
| 42 | + {:ok, repo: repo, user: user, pr: pr} | |
| 43 | + end | |
| 44 | + | |
| 45 | + defp push_runs(repo) do | |
| 46 | + from(r in WorkflowRun, | |
| 47 | + where: r.repository_id == ^repo.id and r.trigger == "push", | |
| 48 | + order_by: r.id | |
| 49 | + ) | |
| 50 | + |> Repo.all() | |
| 51 | + end | |
| 52 | + | |
| 53 | + test "the merge commit gets a push run, by whoever merged", ctx do | |
| 54 | + {:ok, merged} = PullRequests.merge!(ctx.pr, ctx.user) | |
| 55 | + | |
| 56 | + assert_enqueued( | |
| 57 | + worker: CommitBackfill, | |
| 58 | + args: %{ | |
| 59 | + ref: "refs/heads/main", | |
| 60 | + new_sha_hex: GitGud.Git.to_hex(merged.merge_commit_sha), | |
| 61 | + pusher_id: ctx.user.id | |
| 62 | + } | |
| 63 | + ) | |
| 64 | + | |
| 65 | + assert push_runs(ctx.repo) == [] | |
| 66 | + Oban.drain_queue(queue: :git_cache) | |
| 67 | + | |
| 68 | + assert [run] = push_runs(ctx.repo) | |
| 69 | + assert run.head_sha == merged.merge_commit_sha | |
| 70 | + assert run.head_ref == "refs/heads/main" | |
| 71 | + assert run.triggered_by_id == ctx.user.id | |
| 72 | + end | |
| 73 | + | |
| 74 | + test "a merge message with [skip ci] starts no push run", ctx do | |
| 75 | + {:ok, _merged} = PullRequests.merge!(ctx.pr, ctx.user, message: "Merge it [skip ci]") | |
| 76 | + Oban.drain_queue(queue: :git_cache) | |
| 77 | + | |
| 78 | + assert push_runs(ctx.repo) == [] | |
| 79 | + end | |
| 80 | +end |
modified
test/git_gud/workflows/pull_request_activity_test.exs
+2
−0
@@ -98,6 +98,8 @@ defmodule GitGud.Workflows.PullRequestActivityTest do
| 98 | 98 | pr = open_pr!(repo, user, workflow(["closed"])) |
| 99 | 99 | |
| 100 | 100 | {:ok, merged} = PullRequests.merge!(pr, user) |
| 101 | + # The schedule comes with the merge's push, played by the push worker. | |
| 102 | + Oban.drain_queue(queue: :git_cache) | |
| 101 | 103 | drain!() |
| 102 | 104 | |
| 103 | 105 | assert actions(repo) == ["closed"] |
Parents: 9af3f43