A repository created with HEAD on main whose pushes had no main fell back to the most recently changed branch, so the default branch followed every push. Now the fallback prefers main, then master, a push pins a dangling HEAD to one of them, and admins can pick the default branch in the branch settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 6 changed files with 147 additions and 8 deletions.
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useActionState, useEffect, useRef } from "react"; |
| 4 | - import { Checkbox } from "@unlocalhosted/metalui"; | |
| 4 | + import { Checkbox, Select } from "@unlocalhosted/metalui"; | |
| 5 | 5 | import type { FormState } from "@/app/actions/types"; |
| 6 | + import { BranchIcon } from "@/components/icons"; | |
| 6 | 7 | import { ChoiceCards, TextField } from "@/components/settings-fields"; |
| 7 | 8 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 8 | 9 | import { Alert, Button } from "@/components/ui"; |
| @@ -97,3 +98,22 @@ export function AgentPushForm({ action, value, choices }: { action: Action; valu | ||
| 97 | 98 | </form> |
| 98 | 99 | ); |
| 99 | 100 | } |
| 101 | + | |
| 102 | + export function DefaultBranchForm({ action, value, branches }: { action: Action; value: string; branches: string[] }) { | |
| 103 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 104 | + return ( | |
| 105 | + <form action={formAction}> | |
| 106 | + <PlateBody> | |
| 107 | + <span className="type-body text-ink2">What the repository opens on, and where pull requests and new branches start unless you pick another.</span> | |
| 108 | + <Select name="branch" aria-label="Default branch" defaultValue={value} options={branches.map((b) => ({ value: b, label: b, lead: <BranchIcon /> }))} className="w-64" /> | |
| 109 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 110 | + {state?.success && <Alert kind="success">{state.success}</Alert>} | |
| 111 | + </PlateBody> | |
| 112 | + <PlateFooter> | |
| 113 | + <Button variant="primary" disabled={pending}> | |
| 114 | + {pending ? "Saving…" : "Save"} | |
| 115 | + </Button> | |
| 116 | + </PlateFooter> | |
| 117 | + </form> | |
| 118 | + ); | |
| 119 | + } | |
| @@ -1,19 +1,20 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | - import { deleteBranchRuleAction, saveBranchRuleAction, setAgentPushAction } from "@/app/actions/protection"; | |
| 2 | + import { deleteBranchRuleAction, saveBranchRuleAction, setAgentPushAction, setDefaultBranchAction } from "@/app/actions/protection"; | |
| 3 | 3 | import { ConfirmButton } from "@/components/confirm-button"; |
| 4 | 4 | import { PlateSection } from "@/components/settings-plate"; |
| 5 | 5 | import { PageTitle } from "@/components/ui"; |
| 6 | 6 | import { AGENT_PUSH_CHOICES } from "@/lib/branch-rules"; |
| 7 | + import { listBranches } from "@/lib/git"; | |
| 7 | 8 | import { listBranchRules } from "@/lib/protection"; |
| 8 | 9 | import { getRepo } from "@/lib/repo"; |
| 9 | - import { AgentPushForm, RuleForm } from "./forms"; | |
| 10 | + import { AgentPushForm, DefaultBranchForm, RuleForm } from "./forms"; | |
| 10 | 11 | |
| 11 | 12 | export const metadata: Metadata = { title: "Branches" }; |
| 12 | 13 | |
| 13 | 14 | export default async function BranchSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings/branches">) { |
| 14 | 15 | const { owner, repo: repoParam } = await params; |
| 15 | 16 | const repo = await getRepo(owner, repoParam); |
| 16 | - const rules = await listBranchRules(repo.id); | |
| 17 | + const [rules, branches] = await Promise.all([listBranchRules(repo.id), listBranches(repo.dir)]); | |
| 17 | 18 | |
| 18 | 19 | return ( |
| 19 | 20 | <div> |
| @@ -22,6 +23,15 @@ export default async function BranchSettingsPage({ params }: PageProps<"/[owner] | ||
| 22 | 23 | description="Who may change which branches, and what a pull request needs before it's merged. The rules hold for git pushes, the web editor and agents alike." |
| 23 | 24 | /> |
| 24 | 25 | <div className="flex flex-col gap-7"> |
| 26 | + {repo.defaultBranch && ( | |
| 27 | + <PlateSection title="Default branch"> | |
| 28 | + <DefaultBranchForm | |
| 29 | + action={setDefaultBranchAction.bind(null, repo.owner, repo.name)} | |
| 30 | + value={repo.defaultBranch} | |
| 31 | + branches={branches.map((b) => b.name).sort()} | |
| 32 | + /> | |
| 33 | + </PlateSection> | |
| 34 | + )} | |
| 25 | 35 | <PlateSection title="Where agents may push"> |
| 26 | 36 | <AgentPushForm action={setAgentPushAction.bind(null, repo.owner, repo.name)} value={repo.agentPush} choices={AGENT_PUSH_CHOICES} /> |
| 27 | 37 | </PlateSection> |
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | 4 | import { AGENT_PUSH_CHOICES, type AgentPush } from "@/lib/branch-rules"; |
| 5 | + import { GitError, setDefaultBranch } from "@/lib/git"; | |
| 5 | 6 | import { deleteBranchRule, ProtectionError, saveBranchRule } from "@/lib/protection"; |
| 6 | 7 | import { setAgentPush } from "@/lib/repos"; |
| 7 | 8 | import { ActionError, repoForAction } from "./guard"; |
| @@ -53,3 +54,15 @@ export async function setAgentPushAction(owner: string, name: string, _prev: For | ||
| 53 | 54 | throw e; |
| 54 | 55 | } |
| 55 | 56 | } |
| 57 | + | |
| 58 | + export async function setDefaultBranchAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> { | |
| 59 | + try { | |
| 60 | + const { repo } = await repoForAction(owner, name, "admin"); | |
| 61 | + await setDefaultBranch(repo.dir, String(formData.get("branch") ?? "")); | |
| 62 | + revalidatePath(`/${repo.owner}/${repo.name}`, "layout"); | |
| 63 | + return { success: "Saved." }; | |
| 64 | + } catch (e) { | |
| 65 | + if (e instanceof ActionError || e instanceof GitError) return { error: e.message }; | |
| 66 | + throw e; | |
| 67 | + } | |
| 68 | + } | |
| @@ -0,0 +1,66 @@ | ||
| 1 | + // The default branch of a repository whose HEAD dangles (its first push had no main). Run with `bun test`. | |
| 2 | + import assert from "node:assert/strict"; | |
| 3 | + import { execFileSync } from "node:child_process"; | |
| 4 | + import { mkdtempSync, rmSync } from "node:fs"; | |
| 5 | + import os from "node:os"; | |
| 6 | + import path from "node:path"; | |
| 7 | + import { after, before, test } from "node:test"; | |
| 8 | + | |
| 9 | + const tmp = mkdtempSync(path.join(os.tmpdir(), "vectrum-default-branch-test-")); | |
| 10 | + process.env.DATA_ROOT = path.join(tmp, "data"); | |
| 11 | + | |
| 12 | + const sh = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); | |
| 13 | + | |
| 14 | + let git: typeof import("./git"); | |
| 15 | + let repo: string; | |
| 16 | + let work: string; | |
| 17 | + | |
| 18 | + /** Commits on `branch` in the work tree and pushes it, a second later than the last so dates order them. */ | |
| 19 | + let clock = 1_700_000_000; | |
| 20 | + function pushBranch(branch: string) { | |
| 21 | + sh(work, "checkout", "-q", "-B", branch); | |
| 22 | + const date = `${clock++} +0000`; | |
| 23 | + execFileSync("git", ["-c", "user.name=a", "-c", "user.email=a@x", "commit", "-q", "--allow-empty", "-m", branch], { | |
| 24 | + cwd: work, | |
| 25 | + env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, | |
| 26 | + }); | |
| 27 | + sh(work, "push", "-q", repo, `${branch}:refs/heads/${branch}`); | |
| 28 | + } | |
| 29 | + | |
| 30 | + before(async () => { | |
| 31 | + git = await import("./git"); | |
| 32 | + repo = path.join(tmp, "srv.git"); | |
| 33 | + await git.initBareRepo(repo); | |
| 34 | + work = path.join(tmp, "work"); | |
| 35 | + sh(tmp, "init", "-q", "-b", "start", work); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + after(() => rmSync(tmp, { recursive: true, force: true })); | |
| 39 | + | |
| 40 | + test("without main, master wins over a more recently changed branch, and gets pinned", async () => { | |
| 41 | + pushBranch("feature"); | |
| 42 | + await git.pinDefaultBranch(repo); | |
| 43 | + assert.equal(await git.defaultBranch(repo), "feature"); | |
| 44 | + assert.equal(sh(repo, "symbolic-ref", "HEAD").trim(), "refs/heads/main", "HEAD stays dangling without main or master"); | |
| 45 | + | |
| 46 | + pushBranch("master"); | |
| 47 | + pushBranch("settings-parity"); | |
| 48 | + assert.equal(await git.defaultBranch(repo), "master"); | |
| 49 | + await git.pinDefaultBranch(repo); | |
| 50 | + assert.equal(sh(repo, "symbolic-ref", "HEAD").trim(), "refs/heads/master"); | |
| 51 | + | |
| 52 | + // Once pinned, a later main doesn't take over. | |
| 53 | + pushBranch("main"); | |
| 54 | + await git.pinDefaultBranch(repo); | |
| 55 | + assert.equal(await git.defaultBranch(repo), "master"); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + test("the default branch can be changed, but only to a branch that exists", async () => { | |
| 59 | + await git.setDefaultBranch(repo, "feature"); | |
| 60 | + assert.equal(await git.defaultBranch(repo), "feature"); | |
| 61 | + await git.pinDefaultBranch(repo); | |
| 62 | + assert.equal(await git.defaultBranch(repo), "feature"); | |
| 63 | + await assert.rejects(git.setDefaultBranch(repo, "nope"), git.GitError); | |
| 64 | + await assert.rejects(git.setDefaultBranch(repo, "--output=/tmp/x"), git.GitError); | |
| 65 | + assert.equal(await git.defaultBranch(repo), "feature"); | |
| 66 | + }); | |
| @@ -201,13 +201,42 @@ export async function listTags(dir: string) { | ||
| 201 | 201 | return out.split("\n").filter((name) => name && isSafeRefName(name)); |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | - /** The branch HEAD points to, or the first existing branch if HEAD dangles. */ | |
| 204 | + /** Where HEAD points, if that's a branch name (whether or not the branch exists yet). */ | |
| 205 | + async function headBranch(dir: string) { | |
| 206 | + const head = (await git(dir, ["symbolic-ref", "HEAD"]).catch(() => "")).trim(); | |
| 207 | + return head.startsWith("refs/heads/") ? head.slice(11) : null; | |
| 208 | + } | |
| 209 | + | |
| 210 | + /** The default branch when HEAD dangles: main, then master, then the most recently changed branch. */ | |
| 211 | + const fallbackBranch = (branches: Branch[]) => | |
| 212 | + branches.find((b) => b.name === "main")?.name ?? branches.find((b) => b.name === "master")?.name ?? branches[0]?.name ?? null; | |
| 213 | + | |
| 214 | + /** The branch HEAD points to, or (if HEAD dangles) main, master or the most recently changed branch. */ | |
| 205 | 215 | export async function defaultBranch(dir: string): Promise<string | null> { |
| 206 | 216 | const branches = await listBranches(dir); |
| 207 | 217 | if (branches.length === 0) return null; |
| 208 | - const head = (await git(dir, ["symbolic-ref", "--short", "HEAD"]).catch(() => "")).trim(); | |
| 218 | + const head = await headBranch(dir); | |
| 209 | 219 | if (branches.some((b) => b.name === head)) return head; |
| 210 | - return branches[0].name; | |
| 220 | + return fallbackBranch(branches); | |
| 221 | + } | |
| 222 | + | |
| 223 | + /** Makes `branch` (which must exist) the default branch. */ | |
| 224 | + export async function setDefaultBranch(dir: string, branch: string) { | |
| 225 | + if (!(await listBranches(dir)).some((b) => b.name === branch)) throw new GitError(`There's no branch named ${branch}.`); | |
| 226 | + await git(dir, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]); | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** | |
| 230 | + * Points a dangling HEAD (a new repository whose first push had no main, or a deleted default | |
| 231 | + * branch) at main or master once one exists, so the default branch stops following whichever branch | |
| 232 | + * was pushed last. Without either it stays dangling, and defaultBranch() falls back as above. | |
| 233 | + */ | |
| 234 | + export async function pinDefaultBranch(dir: string) { | |
| 235 | + const branches = await listBranches(dir); | |
| 236 | + const head = await headBranch(dir); | |
| 237 | + if (branches.length === 0 || branches.some((b) => b.name === head)) return; | |
| 238 | + const branch = fallbackBranch(branches); | |
| 239 | + if (branch === "main" || branch === "master") await git(dir, ["symbolic-ref", "HEAD", `refs/heads/${branch}`]); | |
| 211 | 240 | } |
| 212 | 241 | |
| 213 | 242 | /** |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { Actor } from "./actor"; |
| 2 | 2 | import { startPushWorkflows } from "./ci"; |
| 3 | 3 | import { emitEvent } from "./events"; |
| 4 | - import { commitsBetween, defaultBranch, getCommit, isAncestor, revParse, type Commit } from "./git"; | |
| 4 | + import { commitsBetween, defaultBranch, getCommit, isAncestor, pinDefaultBranch, revParse, type Commit } from "./git"; | |
| 5 | 5 | import { syncPullsAfterPush } from "./pulls"; |
| 6 | 6 | import type { RepoRecord } from "./repos"; |
| 7 | 7 | |
| @@ -79,6 +79,7 @@ export async function afterRefsUpdated(repo: RepoRecord, before: Map<string, str | ||
| 79 | 79 | if (old !== now && (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/"))) changes.push({ ref, before: old, after: now }); |
| 80 | 80 | } |
| 81 | 81 | if (changes.length === 0) return; |
| 82 | + await pinDefaultBranch(repo.dir); | |
| 82 | 83 | for (const c of changes) await announcePush(repo, c.ref, c.before, c.after, actor); |
| 83 | 84 | await startPushWorkflows(repo, before, after, actor); |
| 84 | 85 | await syncPullsAfterPush( |