Fix: a push of another branch no longer resets the default branch

1e5369d · Gabriel Morell · 2026-10-22 22:49

9 files +131 -11
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/repositories.ex
+3 −0
@@ -1765,6 +1765,9 @@ defmodule GitGud.Repositories do
1765 1765 |> tap(fn
1766 1766 # `on: schedule` follows the default branch.
1767 1767 {:ok, %Repository{default_branch: branch} = updated} when branch != repo.default_branch ->
1768 + # The bare repository's HEAD follows, so a clone checks out
1769 + # the default and nothing reading HEAD disagrees with it.
1770 + :ok = set_default_head(updated.disk_path, branch)
1768 1771 {:ok, _count} = GitGud.Workflows.Schedules.sync(updated)
1769 1772
1770 1773 _ ->
modified lib/git_gud/repositories/hook_receiver.ex
+54 −11
@@ -61,10 +61,17 @@ defmodule GitGud.Repositories.HookReceiver do
61 61 end
62 62 end
63 63
64 − # If the repo's recorded default_branch no longer resolves on disk
64 + # If the repo's recorded default_branch doesn't resolve on disk
65 65 # (typical: empty repo created with default "main", first push lands
66 66 # on "master"), align default_branch with HEAD so the UI doesn't
67 67 # render "This repository is empty" against a populated repo.
68 + #
69 + # Only then: a push that simply doesn't touch the default branch — a
70 + # feature branch, a CI job's update branch — says nothing about which
71 + # branch is the default, and a bare HEAD left at git's own "main" must
72 + # not overwrite a configured "master" that exists. Where the
73 + # configured branch does exist, HEAD is pointed at it instead, so the
74 + # two stop disagreeing.
68 75 defp sync_default_branch_if_unset(repository_id, triples) do
69 76 repo = GitGud.Repo.get(Repository, repository_id)
70 77
@@ -74,20 +81,56 @@ defmodule GitGud.Repositories.HookReceiver do
74 81 configured_pushed? =
75 82 Enum.any?(triples, fn {_, _, ref} -> ref == configured end)
76 83
77 − unless configured_pushed? do
78 − case read_head_branch(repo.disk_path) do
79 − {:ok, branch} when branch != repo.default_branch ->
80 − repo
81 − |> Ecto.Changeset.change(default_branch: branch)
82 − |> GitGud.Repo.update()
83 −
84 − _ ->
85 − :ok
86 − end
84 + cond do
85 + configured_pushed? ->
86 + :ok
87 +
88 + branch_exists?(repo.disk_path, configured) ->
89 + point_head_at(repo.disk_path, configured)
90 +
91 + true ->
92 + case read_head_branch(repo.disk_path) do
93 + {:ok, branch} when branch != repo.default_branch ->
94 + if branch_exists?(repo.disk_path, "refs/heads/" <> branch) do
95 + repo
96 + |> Ecto.Changeset.change(default_branch: branch)
97 + |> GitGud.Repo.update()
98 + end
99 +
100 + _ ->
101 + :ok
102 + end
87 103 end
88 104 end
89 105 end
90 106
107 + defp branch_exists?(disk_path, "refs/heads/" <> name = ref) when name != "" do
108 + match?(
109 + {_, 0},
110 + System.cmd("git", ["-C", disk_path, "rev-parse", "--verify", "--quiet", ref <> "^{commit}"],
111 + stderr_to_stdout: true
112 + )
113 + )
114 + end
115 +
116 + defp branch_exists?(_disk_path, _ref), do: false
117 +
118 + defp point_head_at(disk_path, ref) do
119 + case File.read(Path.join(disk_path, "HEAD")) do
120 + {:ok, "ref: " <> current} when current != "" ->
121 + if String.trim(current) != ref,
122 + do:
123 + System.cmd("git", ["-C", disk_path, "symbolic-ref", "HEAD", ref],
124 + stderr_to_stdout: true
125 + )
126 +
127 + _ ->
128 + :ok
129 + end
130 +
131 + :ok
132 + end
133 +
91 134 # Stamp pushed_at so repo listings can show "pushed N ago". Skip
92 135 # delete-only pushes (all triples have a zero new SHA) since they
93 136 # aren't really a push of content.
added test/git_gud/repositories/default_branch_sync_test.exs
+74 −0
@@ -0,0 +1,74 @@
1 +defmodule GitGud.Repositories.DefaultBranchSyncTest do
2 + @moduledoc """
3 + What a push may do to a repository's default branch: realign it with
4 + the bare repository's HEAD when the configured one doesn't exist (an
5 + empty repo made as "main", first pushed as "master") — and nothing
6 + when it does, however HEAD reads. A push of some other branch once
7 + reset a configured, existing "master" to a "main" no one had.
8 + """
9 + use GitGud.DataCase, async: false
10 +
11 + import GitGud.ForgeFixtures
12 + import GitGud.WorkflowGitHelpers
13 +
14 + alias GitGud.Repositories
15 + alias GitGud.Repositories.HookReceiver
16 + alias GitGud.Repositories.Repository
17 +
18 + @zero String.duplicate("0", 40)
19 +
20 + defp push(repo, branch, sha),
21 + do: HookReceiver.receive(repo.id, "#{@zero} #{sha} refs/heads/#{branch}\n")
22 +
23 + defp head(repo), do: repo.disk_path |> Path.join("HEAD") |> File.read!() |> String.trim()
24 +
25 + setup do
26 + {_owner, repo} = repository_fixture()
27 + main = commit!(repo, %{"a" => "a\n"}, [])
28 + {:ok, repo: repo, sha: main}
29 + end
30 +
31 + test "another branch's push leaves an existing default alone, and HEAD follows it", %{
32 + repo: repo,
33 + sha: sha
34 + } do
35 + repo = Repo.update!(Ecto.Changeset.change(repo, default_branch: "master"))
36 + git!(repo.disk_path, ["update-ref", "refs/heads/master", sha])
37 + git!(repo.disk_path, ["update-ref", "refs/heads/deps/security-hex", sha])
38 + # git's own default, as an old bare repo may still have it.
39 + git!(repo.disk_path, ["symbolic-ref", "HEAD", "refs/heads/main"])
40 +
41 + push(repo, "deps/security-hex", sha)
42 +
43 + assert Repo.get!(Repository, repo.id).default_branch == "master"
44 + assert head(repo) == "ref: refs/heads/master"
45 + end
46 +
47 + test "a default that doesn't exist is realigned with HEAD", %{repo: repo, sha: sha} do
48 + repo = Repo.update!(Ecto.Changeset.change(repo, default_branch: "main"))
49 + git!(repo.disk_path, ["update-ref", "refs/heads/master", sha])
50 + git!(repo.disk_path, ["symbolic-ref", "HEAD", "refs/heads/master"])
51 +
52 + push(repo, "master", sha)
53 +
54 + assert Repo.get!(Repository, repo.id).default_branch == "master"
55 + end
56 +
57 + test "…but not to a HEAD that doesn't exist either", %{repo: repo, sha: sha} do
58 + repo = Repo.update!(Ecto.Changeset.change(repo, default_branch: "trunk"))
59 + git!(repo.disk_path, ["update-ref", "refs/heads/feature", sha])
60 + git!(repo.disk_path, ["symbolic-ref", "HEAD", "refs/heads/main"])
61 +
62 + push(repo, "feature", sha)
63 +
64 + assert Repo.get!(Repository, repo.id).default_branch == "trunk"
65 + end
66 +
67 + test "changing the default in settings points HEAD at it", %{repo: repo, sha: sha} do
68 + git!(repo.disk_path, ["update-ref", "refs/heads/trunk", sha])
69 +
70 + {:ok, _} = Repositories.update_repository(repo, %{"default_branch" => "trunk"})
71 +
72 + assert head(repo) == "ref: refs/heads/trunk"
73 + end
74 +end

Parents: c91f312