neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Fix: a push of another branch no longer resets the default branch
1e5369d · Gabriel Morell · 2026-10-22 22:49
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