Branches the forge moves itself get the push a push would

5e43417 · Gabriel Morell · 2026-10-18 18:18

18 files +201 -15
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