Vectrum
Sign In

Keep the default branch on main or master when HEAD dangles

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>
JjohannSocommitted 1 parent 7674d75commit9f3f615

Showing 6 changed files with 147 additions and 8 deletions.

+21−1apps/web/app/[owner]/[repo]/settings/branches/forms.tsxView File
@@ -1,8 +1,9 @@
11 "use client";
22
33 import { useActionState, useEffect, useRef } from "react";
4- import { Checkbox } from "@unlocalhosted/metalui";
4+ import { Checkbox, Select } from "@unlocalhosted/metalui";
55 import type { FormState } from "@/app/actions/types";
6+ import { BranchIcon } from "@/components/icons";
67 import { ChoiceCards, TextField } from "@/components/settings-fields";
78 import { PlateBody, PlateFooter } from "@/components/settings-plate";
89 import { Alert, Button } from "@/components/ui";
@@ -97,3 +98,22 @@ export function AgentPushForm({ action, value, choices }: { action: Action; valu
9798 </form>
9899 );
99100 }
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+ }
+13−3apps/web/app/[owner]/[repo]/settings/branches/page.tsxView File
@@ -1,19 +1,20 @@
11 import type { Metadata } from "next";
2- import { deleteBranchRuleAction, saveBranchRuleAction, setAgentPushAction } from "@/app/actions/protection";
2+ import { deleteBranchRuleAction, saveBranchRuleAction, setAgentPushAction, setDefaultBranchAction } from "@/app/actions/protection";
33 import { ConfirmButton } from "@/components/confirm-button";
44 import { PlateSection } from "@/components/settings-plate";
55 import { PageTitle } from "@/components/ui";
66 import { AGENT_PUSH_CHOICES } from "@/lib/branch-rules";
7+ import { listBranches } from "@/lib/git";
78 import { listBranchRules } from "@/lib/protection";
89 import { getRepo } from "@/lib/repo";
9- import { AgentPushForm, RuleForm } from "./forms";
10+ import { AgentPushForm, DefaultBranchForm, RuleForm } from "./forms";
1011
1112 export const metadata: Metadata = { title: "Branches" };
1213
1314 export default async function BranchSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings/branches">) {
1415 const { owner, repo: repoParam } = await params;
1516 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)]);
1718
1819 return (
1920 <div>
@@ -22,6 +23,15 @@ export default async function BranchSettingsPage({ params }: PageProps<"/[owner]
2223 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."
2324 />
2425 <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+ )}
2535 <PlateSection title="Where agents may push">
2636 <AgentPushForm action={setAgentPushAction.bind(null, repo.owner, repo.name)} value={repo.agentPush} choices={AGENT_PUSH_CHOICES} />
2737 </PlateSection>
+13−0apps/web/app/actions/protection.tsView File
@@ -2,6 +2,7 @@
22
33 import { revalidatePath } from "next/cache";
44 import { AGENT_PUSH_CHOICES, type AgentPush } from "@/lib/branch-rules";
5+ import { GitError, setDefaultBranch } from "@/lib/git";
56 import { deleteBranchRule, ProtectionError, saveBranchRule } from "@/lib/protection";
67 import { setAgentPush } from "@/lib/repos";
78 import { ActionError, repoForAction } from "./guard";
@@ -53,3 +54,15 @@ export async function setAgentPushAction(owner: string, name: string, _prev: For
5354 throw e;
5455 }
5556 }
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+ }
+66−0apps/web/lib/default-branch.test.tsaddedView File
@@ -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+ });
+32−3apps/web/lib/git.tsView File
@@ -201,13 +201,42 @@ export async function listTags(dir: string) {
201201 return out.split("\n").filter((name) => name && isSafeRefName(name));
202202 }
203203
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. */
205215 export async function defaultBranch(dir: string): Promise<string | null> {
206216 const branches = await listBranches(dir);
207217 if (branches.length === 0) return null;
208- const head = (await git(dir, ["symbolic-ref", "--short", "HEAD"]).catch(() => "")).trim();
218+ const head = await headBranch(dir);
209219 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}`]);
211240 }
212241
213242 /**
+2−1apps/web/lib/push.tsView File
@@ -1,7 +1,7 @@
11 import type { Actor } from "./actor";
22 import { startPushWorkflows } from "./ci";
33 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";
55 import { syncPullsAfterPush } from "./pulls";
66 import type { RepoRecord } from "./repos";
77
@@ -79,6 +79,7 @@ export async function afterRefsUpdated(repo: RepoRecord, before: Map<string, str
7979 if (old !== now && (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/"))) changes.push({ ref, before: old, after: now });
8080 }
8181 if (changes.length === 0) return;
82+ await pinDefaultBranch(repo.dir);
8283 for (const c of changes) await announcePush(repo, c.ref, c.before, c.after, actor);
8384 await startPushWorkflows(repo, before, after, actor);
8485 await syncPullsAfterPush(