Skip to content

Commit

The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days.

People who can push get New branch (a name, and a branch to start from, or a tag or commit typed in), a delete button on each branch but the default, which asks first only when the branch has commits the default branch lacks or could not be measured, and then keeps the row as Deleted with Restore (making it again at the same commit) until the page is left, and a ⋯ menu with New pull request (the compare page), Activity (the repository's activity kept to that branch with ?branch=), View rules (Admin: the rules that hold for it) and Rename branch, whose dialog says what happens to open pull requests, addresses and clones. Renaming the default branch stays Admin's. Admins see that the default branch isn't protected when no ruleset holds for it, with Protect this branch (a new ruleset) and Dismiss, remembered by the browser per person and repository. The repos service gains create_branch (a branch at a branch, tag or commit; the name must be valid, free, and neither a folder of another branch nor holding one) and remove_branch (never the default branch; with head, a branch that moved is left alone, and it answers with the commit it pointed to, for restoring). Both need push, hold the rules for creating and deleting refs, report the ref change, publish git.push or git.deleted, and write branch.created or branch.deleted to the audit log. The API has POST /repos/{owner}/{name}/branches (create_branch) and DELETE /repos/{owner}/{name}/branches/{branch}?head= (delete_branch), with repo:write in both scope tables; g1t's token never deletes a branch. Activity lists deleted branches with their commit. The Git guide describes the page, its tabs, columns and actions, the restore window, who may do what and the routes; Managing a repository, the MCP reference and the API reference follow.

syntaqxcommitted Parent7e35aebBrowse files
27 files+1162−250/27 viewed
+4−0
122122 Op::UpdateRepo,
123123 Op::RenameRepo,
124124 Op::RenameBranch,
125+ Op::CreateBranch,
126+ Op::DeleteBranch,
125127 Op::SetRepoVisibility,
126128 Op::ArchiveRepo,
127129 Op::UnarchiveRepo,
654656 Op::TransferRepo => "Transfer a repository",
655657 Op::RenameRepo => "Rename a repository",
656658 Op::RenameBranch => "Rename a branch",
659+ Op::CreateBranch => "Create a branch",
660+ Op::DeleteBranch => "Delete a branch",
657661 Op::ArchiveRepo => "Archive a repository",
658662 Op::UnarchiveRepo => "Unarchive a repository",
659663 Op::SetRepoVisibility => "Change a repository's visibility",
+69−1
143143 TransferRepo,
144144 RenameRepo,
145145 RenameBranch,
146+ CreateBranch,
147+ DeleteBranch,
146148 ArchiveRepo,
147149 UnarchiveRepo,
148150 SetRepoVisibility,
711713 }
712714
713715 impl Op {
714− pub const ALL: [Op; 380] = [
716+ pub const ALL: [Op; 382] = [
715717 Op::Whoami,
716718 Op::GetWorkspace,
717719 Op::CreateWorkspace,
743745 Op::TransferRepo,
744746 Op::RenameRepo,
745747 Op::RenameBranch,
748+ Op::CreateBranch,
749+ Op::DeleteBranch,
746750 Op::ArchiveRepo,
747751 Op::UnarchiveRepo,
748752 Op::SetRepoVisibility,
11321136 Op::TransferRepo => "transfer_repo",
11331137 Op::RenameRepo => "rename_repo",
11341138 Op::RenameBranch => "rename_branch",
1139+ Op::CreateBranch => "create_branch",
1140+ Op::DeleteBranch => "delete_branch",
11351141 Op::ArchiveRepo => "archive_repo",
11361142 Op::UnarchiveRepo => "unarchive_repo",
11371143 Op::SetRepoVisibility => "set_repo_visibility",
13971403 Op::RenameBranch => {
13981404 "Rename a branch. Needs the Write role or higher; the default branch, which stays the default, needs the Admin role. Open pull requests from the branch follow it, and web addresses that name the old branch redirect until a branch of that name is made again. Git remotes do not follow: fetch, then rename or re-track the branch in your clone. Give a branch with slashes URL-encoded in the path, e.g. feature%2Flogin."
13991405 }
1406+ Op::CreateBranch => {
1407+ "Make a branch at another branch, a tag or a commit (from; the default branch when left out). Needs the Write role or higher, and the repository's rules for creating the branch hold. The name may not be taken, and may not be a folder of another branch's name or hold one (feature cannot sit beside feature/login). To restore a deleted branch, make it again at the commit delete_branch answered with. Returns the new branch and the commit it points to."
1408+ }
1409+ Op::DeleteBranch => {
1410+ "Delete a branch. Needs the Write role or higher, and the repository's rules for deleting the branch hold; the default branch is never deleted. Give head, the commit you saw it at, to leave it alone if someone has pushed since. Returns the branch as it was: its commits stay in the repository, so create_branch with that commit as from restores it. Open pull requests from it can no longer be updated or merged until it is back. Give a branch with slashes URL-encoded in the path, e.g. feature%2Flogin."
1411+ }
14001412 Op::ArchiveRepo => {
14011413 "Archive a repository: make it read-only. Needs the Admin role. Pushes and merges are refused, issues and pull requests are locked, and agents and workflows do not run. It can still be read, cloned and searched, and its deployments keep serving. unarchive_repo makes it writable again."
14021414 }
22112223 }),
22122224 &["repo", "branch", "new_name"],
22132225 ),
2226+ Op::CreateBranch => object(
2227+ json!({
2228+ "repo": repo_schema(),
2229+ "name": {
2230+ "type": "string",
2231+ "description": "The new branch's name, e.g. \"feature/login\".",
2232+ },
2233+ "from": {
2234+ "type": "string",
2235+ "description": "The branch, tag or commit to start it from. The default branch when left out.",
2236+ },
2237+ }),
2238+ &["repo", "name"],
2239+ ),
2240+ Op::DeleteBranch => object(
2241+ json!({
2242+ "repo": repo_schema(),
2243+ "branch": {
2244+ "type": "string",
2245+ "description": "The branch to delete, e.g. \"feature/login\". URL-encode slashes in the path.",
2246+ },
2247+ "head": {
2248+ "type": "string",
2249+ "description": "The commit you saw it at. If it has moved since, it is left alone (409).",
2250+ },
2251+ }),
2252+ &["repo", "branch"],
2253+ ),
22142254 Op::ArchiveRepo | Op::UnarchiveRepo | Op::RestoreRepo => repo_only(),
22152255 Op::SetRepoVisibility => object(
22162256 json!({
42024242 )
42034243 .await
42044244 }
4245+ Op::CreateBranch => {
4246+ pass(
4247+ repos,
4248+ "create_branch",
4249+ &json!({
4250+ "actor": actor(),
4251+ "path": repo,
4252+ "name": text(input, "name"),
4253+ "from": text(input, "from"),
4254+ "surface": services.audit.surface,
4255+ }),
4256+ )
4257+ .await
4258+ }
4259+ Op::DeleteBranch => {
4260+ pass(
4261+ repos,
4262+ "remove_branch",
4263+ &json!({
4264+ "actor": actor(),
4265+ "path": repo,
4266+ "branch": text(input, "branch"),
4267+ "head": optional_text(input, "head"),
4268+ "surface": services.audit.surface,
4269+ }),
4270+ )
4271+ .await
4272+ }
42054273 Op::ArchiveRepo | Op::UnarchiveRepo => {
42064274 pass(
42074275 repos,
+23−0
771771 },
772772 "notes": "The repository after the rename; renaming the default branch changes `default_branch`. Send a branch name with slashes URL-encoded, as one path segment. Refused with `404` when there is no such branch, `409` when a branch named `new_name` exists, `422` when `new_name` is not a valid branch name, and `403` without the Write role, or without the Admin role for the default branch. See [Rename a branch](/guides/managing-repositories/#rename-a-branch)."
773773 },
774+ "create_branch": {
775+ "request": {
776+ "name": "feature/login",
777+ "from": "main"
778+ },
779+ "response": {
780+ "name": "feature/login",
781+ "hash": "9f2c4e1b7a3d5c6e8f0a1b2c3d4e5f6a7b8c9d0e"
782+ },
783+ "notes": "The new branch and the commit it points to. `from` is a branch, a tag or a commit, the default branch when left out; to restore a deleted branch, send the `hash` that deleting it answered with. Refused with `404` when `from` names nothing, `409` when the name is taken or is a folder of another branch's name (or holds one), `422` when it is not a valid branch name, and `403` without the Write role or when the repository's rules refuse it. See [Branches](/guides/git/#branches)."
784+ },
785+ "delete_branch": {
786+ "params": {
787+ "owner": "flagon-io",
788+ "name": "hello",
789+ "branch": "feature/login"
790+ },
791+ "response": {
792+ "name": "feature/login",
793+ "hash": "9f2c4e1b7a3d5c6e8f0a1b2c3d4e5f6a7b8c9d0e"
794+ },
795+ "notes": "The branch as it was. Its commits stay in the repository: `POST /repos/{owner}/{name}/branches` with this `hash` as `from` restores it. Add `?head=<sha>` to leave it alone (`409`) if it has moved from the commit you saw. Refused with `403` for the default branch, without the Write role, or when the repository's rules refuse it, and `404` when there is no such branch. Send a branch name with slashes URL-encoded, as one path segment. See [Branches](/guides/git/#branches)."
796+ },
774797 "set_repo_visibility": {
775798 "request": {
776799 "private": true,
+1−0
159159 | Op::UnarchiveRepo
160160 | Op::SetRepoVisibility
161161 | Op::RestoreRepo => through::<repos::Repo>(op, sent),
162+ Op::CreateBranch | Op::DeleteBranch => through::<repos::Branch>(op, sent),
162163 Op::DeleteRepo => through::<repos::DeletedRepo>(op, sent),
163164 Op::ListDeletedRepos => through::<Vec<repos::DeletedRepo>>(op, sent),
164165 Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent),
+2−0
300300 Op::RenameBranch,
301301 &[],
302302 ),
303+ route("POST", "/repos/:owner/:name/branches", Op::CreateBranch, &[]),
304+ route("DELETE", "/repos/:owner/:name/branches/:branch", Op::DeleteBranch, &[("head", "head")]),
303305 route(
304306 "GET",
305307 "/repos/:owner/:name/settings",
+2−0
115115 a("update_release", Op::About(AboutOp::UpdateRelease), "Change a release's title, notes, draft or prerelease"),
116116 a("delete_release", Op::About(AboutOp::DeleteRelease), "Delete a release; its tag stays"),
117117 a("rename_branch", Op::RenameBranch, "Rename a branch"),
118+ a("create_branch", Op::CreateBranch, "Make a branch at a branch, tag or commit"),
119+ a("delete_branch", Op::DeleteBranch, "Delete a branch; make it again at its commit to restore it"),
118120 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
119121 a("transfer", Op::TransferRepo, "Move it to another workspace you own"),
120122 a("mirror", Op::Mirrors(MirrorsOp::GetMirror), "Its remotes: what it mirrors or is mirrored to"),
+80−9
160160 git push origin my-change
161161 ```
162162
163+### The Branches page
164+
163165 A repository's **Branches** tab, `g1t.sh/<workspace>/<repo>/branches`, lists
164−every branch: the default one first, then those with a commit in the last 90
165−days (**Active**), then the rest (**Stale**). Each shows its last commit, how
166−many commits it is ahead of and behind the default branch, the pull request
167−open on it with its checks, and its preview when it has one. A branch with
168−no pull request links to opening one; one with nothing the default branch
169−lacks (ahead `0`) says **Nothing to merge** instead. The counts are exact,
170−merges included. g1t reads up to 1,000 commits of each history to find
171−where the two meet; a branch that left the default branch further back
172−than that shows no counts. Search narrows the list by name.
166+its branches under five tabs, kept in the address as `?tab=`:
167+
168+| Tab | What it lists |
169+| --- | --- |
170+| **Overview** | The default branch, then the first five of each of the others below that has any: yours, active and stale, each with a link to the rest. |
171+| **Yours** (`?tab=yours`) | Branches whose latest commit is yours. |
172+| **Active** (`?tab=active`) | Branches with a commit in the last 90 days. |
173+| **Stale** (`?tab=stale`) | Branches with no commit in the last 90 days. |
174+| **All** (`?tab=all`) | Every branch but the default, newest commit first. |
175+
176+**Search branches** narrows any tab by name (`?q=`). Each branch shows:
177+
178+| Column | What it says |
179+| --- | --- |
180+| **Branch** | Its name, linked to its files, with a button that copies it; a globe when it has a preview. |
181+| **Updated** | Who made its latest commit, and when, linked to the commit. |
182+| **Check status** | All of its latest commit's checks at once: a check when they passed, a cross when one failed, a dot while any runs. Choose it to see each. |
183+| **Behind / Ahead** | How many commits the default branch has that it lacks, and the other way round, with bars drawn against the largest count shown. The counts are exact, merges included. g1t reads up to 1,000 commits of each history to find where the two meet; a branch that left the default branch further back than that shows no counts. |
184+| **Pull request** | Its open pull request, else its newest merged or closed one, with its state and number. |
185+
186+On a phone, the columns fold under the name. Up to 60 branches are read for
187+a page, those with an open pull request first; search finds the rest.
188+
189+People with the Write role or higher can change branches from the page:
190+
191+- **New branch** makes a branch from another branch, or from a tag or a
192+ commit, which you type in.
193+- The trash button deletes a branch. A branch with commits the default
194+ branch lacks, or whose counts could not be read, asks first; a merged
195+ one (nothing ahead, or its pull request merged) goes at once. It stays in
196+ the list as **Deleted**, with **Restore**, which makes it again at the
197+ same commit, until you leave the page. While it is gone, a pull request
198+ from it can be neither updated nor merged. A deletion is listed in the
199+ repository's **Activity** with the commit it pointed to, so it can still
200+ be restored later from the API.
201+- The **⋯** menu opens a **New pull request** (the compare page against the
202+ default branch), the branch's **Activity**, the **View rules** that hold
203+ for it (Admin), and **Rename branch**. See
204+ [rename a branch](/guides/managing-repositories/#rename-a-branch) for what
205+ renaming does to pull requests, addresses and clones.
206+
207+The default branch is never deleted, and renaming it needs Admin.
208+[Rules](/guides/rules/) hold for every one of these changes: a ruleset that
209+restricts creating, deleting or renaming a branch refuses it here as it
210+would a push, and says why. When nothing protects the default branch,
211+people with the Admin role see a note saying so, with **Protect this
212+branch**, which starts a [ruleset](/guides/rules/), and **Dismiss**, which
213+this browser remembers for them and that repository. An archived
214+repository, or a mirror that g1t has not taken over, offers none of these
215+changes.
216+
217+From the API, a person's token with `repo:write` can do the same:
218+
219+| Route | MCP | What it does |
220+| --- | --- | --- |
221+| [`POST /repos/{owner}/{name}/branches`](/reference/api/repositories/create-branch/) | `repository` `create_branch` | Makes branch `name` at `from` (a branch, tag or commit; the default branch when left out). Answers with its `name` and `hash`. |
222+| [`DELETE /repos/{owner}/{name}/branches/{branch}`](/reference/api/repositories/delete-branch/) | `repository` `delete_branch` | Deletes it and answers with the `hash` it pointed to. `?head=<sha>` leaves it alone (`409`) if it has moved since. |
223+| [`POST /repos/{owner}/{name}/branches/{branch}/rename`](/reference/api/repositories/rename-branch/) | `repository` `rename_branch` | Renames it to `new_name`. |
224+
225+To restore a deleted branch, make it again with the `hash` deleting it
226+answered with as `from`. Encode a branch name with slashes as one path
227+segment: `feature/login` is `feature%2Flogin`.
228+
229+```sh
230+curl -X DELETE https://api.g1t.sh/repos/acme/rocket/branches/feature%2Flogin \
231+ -H "Authorization: Bearer $G1T_TOKEN"
232+# {"name": "feature/login", "hash": "9f2c4e1b…"}
233+
234+curl -X POST https://api.g1t.sh/repos/acme/rocket/branches \
235+ -H "Authorization: Bearer $G1T_TOKEN" \
236+ -H "Content-Type: application/json" \
237+ -d '{"name": "feature/login", "from": "9f2c4e1b…"}'
238+```
239+
240+Making a branch sends `git.push`, and deleting one sends `git.deleted`, to
241+[webhooks](/guides/webhooks/#events), as a push would; both
242+are recorded in the [audit log](/guides/audit-log/) as `branch.created` and
243+`branch.deleted`. g1t's token never deletes a branch.
173244
174245 The **Tags** tab lists tags newest first, up to 100, each with its commit
175246 and a ZIP of its files.
+11−2
7878 ### Activity
7979
8080 **Insights → Activity** lists, newest first, who pushed to which branch,
81−created a branch or tag, merged a pull request, renamed a branch or changed
82−the default branch, person or agent.
81+created a branch or tag, deleted a branch (with the commit it pointed to),
82+merged a pull request, renamed a branch or changed the default branch,
83+person or agent. A branch's **Activity**, from its **⋯** menu on the
84+[Branches page](/guides/git/#the-branches-page), keeps only what happened to
85+that branch (`?branch=<name>`); each page of the repository's activity is
86+read and filtered, so **Older** may be needed to reach earlier changes.
8387
8488 ### Stars
8589
111115 | Description, website, topics | Maintain |
112116 | How long artifacts are kept | Maintain |
113117 | Default branch | Admin |
118+| Create, delete or restore a branch | Write; the default branch is never deleted. See [branches](/guides/git/#the-branches-page) |
114119 | Rename a branch | Write; Admin for the default branch |
115120 | Rename the repository | Admin |
116121 | Make it public or private | Admin, and the [member privileges](/guides/workspaces/#member-privileges) to allow it, or an owner |
190195 **New name**.
191196 3. Choose **Rename branch**.
192197
198+Or, on the repository's [Branches page](/guides/git/#the-branches-page),
199+open the branch's **⋯** menu and choose **Rename branch**.
200+
193201 Renaming the default branch needs Admin. It stays the default under its
194202 new name. When a branch is renamed:
195203
491499 | Rename | `repo.renamed` |
492500 | Default branch | `repo.default_branch_changed` |
493501 | Branch rename | `branch.renamed` |
502+| Branch made or deleted | `git.push`, `git.deleted`; in the audit log, `branch.created`, `branch.deleted` |
494503 | Archive, unarchive | `repo.archived`, `repo.unarchived` |
495504 | Transfer | `repo.transferred` |
496505 | Delete, restore, purge | `repo.deleted`, `repo.restored`, `repo.purged` |
+2−0
287287 | [`update_release`](/reference/api/releases/update-release/) | Change its `release_name`, `body`, `draft` or `prerelease`. Write role. | `repo`, `id` | `repo:write` |
288288 | [`delete_release`](/reference/api/releases/delete-release/) | Delete a release; its tag stays. Write role. | `repo`, `id` | `repo:write` |
289289 | [`rename_branch`](/reference/api/repositories/rename-branch/) | Rename a branch; its pull requests follow, and web addresses that name the old branch redirect. Write role; the default branch needs Admin. | `repo`, `branch`, `new_name` | `repo:write` |
290+| [`create_branch`](/reference/api/repositories/create-branch/) | Make a branch at another branch, a tag or a commit (`from`; the default branch when left out). Making one again at the commit `delete_branch` answered with restores it. Write role; the rules for creating it hold. | `repo`, `name` | `repo:write` |
291+| [`delete_branch`](/reference/api/repositories/delete-branch/) | Delete a branch, never the default; answers with the commit it pointed to. With `head`, a branch that has moved is left alone. Write role; the rules for deleting it hold. People only: g1t's token never deletes a branch. | `repo`, `branch` | `repo:write` |
290292 | [`rename`](/reference/api/repositories/rename-repo/) | Give it a new name in its workspace; the old address redirects. Admin role. | `repo`, `name` | `repo:admin` |
291293 | [`transfer`](/reference/api/repositories/transfer-repo/) | Move it to another workspace, keeping its name; the old address redirects. An owner, or an Admin when the member privileges allow it; and someone who can create a repository in the other workspace. See [transferring a repository](/guides/transferring-repositories/). | `repo`, `to` | `repo:admin` |
292294 | [`archive`](/reference/api/repositories/archive-repo/) | Make it read-only: pushes and merges are refused, issues and pull requests are locked, agents and workflows stop. Admin role. | `repo` | `repo:admin` |
+0−0

Binary or large file; its contents are not shown.

+707−0
1+/**
2+ * The Branches page's list: each branch with its latest commit's author
3+ * and time, its checks, how far it is behind and ahead of the default
4+ * branch, and its pull request, and, for people who can push, deleting it
5+ * (and restoring it for the rest of the visit), renaming it, and making a
6+ * new branch. The page's action (routes/repo/branches.tsx) does the work.
7+ */
8+import {
9+ Activity,
10+ Check,
11+ Copy,
12+ GitBranch,
13+ GitBranchPlus,
14+ GitPullRequestArrow,
15+ Globe,
16+ LoaderCircle,
17+ MoreHorizontal,
18+ PenLine,
19+ ShieldCheck,
20+ ShieldAlert,
21+ Trash2,
22+ Undo2,
23+ X,
24+} from "lucide-react";
25+import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
26+import { Link, useFetcher } from "react-router";
27+
28+import type { ActiveBranch } from "./branches";
29+import type { ChecksSource } from "./commit-checks";
30+import { CommitChecksBadge } from "./commit-checks";
31+import { CommitAvatars } from "./commit-person";
32+import { host } from "./deploy";
33+import { CopyLine, TimeAgo } from "./ui";
34+import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
35+import {
36+ AlertDialog,
37+ AlertDialogAction,
38+ AlertDialogCancel,
39+ AlertDialogContent,
40+ AlertDialogDescription,
41+ AlertDialogFooter,
42+ AlertDialogHeader,
43+ AlertDialogTitle,
44+} from "./ui/alert-dialog";
45+import { Badge } from "./ui/badge";
46+import { Button } from "./ui/button";
47+import { Combobox } from "./ui/combobox";
48+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
49+import {
50+ DropdownMenu,
51+ DropdownMenuContent,
52+ DropdownMenuItem,
53+ DropdownMenuSeparator,
54+ DropdownMenuTrigger,
55+} from "./ui/dropdown-menu";
56+import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
57+import { Hint } from "./ui/hint";
58+import { Input } from "./ui/input";
59+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
60+import { PullIcon } from "./work-icons";
61+import { isMerged } from "../lib/branches";
62+
63+/** What the page's action answers. */
64+export type BranchActionData = { intent: string; branch?: string; hash?: string; error?: string };
65+
66+/** A row as the list shows it: a branch, or one deleted on this visit. */
67+export type BranchRowData = ActiveBranch & { deleted?: boolean };
68+
69+/** What every row needs to know about the page. */
70+export type BranchListContext = {
71+ base: string;
72+ main: string;
73+ checks: ChecksSource;
74+ /** The longest behind or ahead count shown: a full bar. */
75+ scale: number;
76+ /** May push: make, delete, restore and rename branches. */
77+ canPush: boolean;
78+ /** May rename the default branch (Admin). */
79+ canRenameDefault: boolean;
80+ /** May see the rules (Admin). */
81+ canViewRules: boolean;
82+ onDeleted: (row: ActiveBranch) => void;
83+ onRestored: (name: string) => void;
84+ onError: (message: string) => void;
85+};
86+
87+/** A branch name in an address: each part encoded, its slashes kept. */
88+export function refPath(name: string): string {
89+ return name.split("/").map(encodeURIComponent).join("/");
90+}
91+
92+/** A small copy button: a check for a moment once copied. */
93+function CopyButton({ text, label }: { text: string; label: string }) {
94+ const [copied, setCopied] = useState(false);
95+ return (
96+ <Hint label={copied ? "Copied" : label}>
97+ <Button
98+ type="button"
99+ variant="ghost"
100+ size="inline"
101+ aria-label={label}
102+ className="p-1 text-faint opacity-70 group-hover/row:opacity-100 focus-visible:opacity-100"
103+ onClick={() => {
104+ void navigator.clipboard.writeText(text).then(() => {
105+ setCopied(true);
106+ setTimeout(() => setCopied(false), 1500);
107+ });
108+ }}
109+ >
110+ {copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
111+ </Button>
112+ </Hint>
113+ );
114+}
115+
116+/** The branch's name in a code chip, linked to its files, with a copy button. */
117+function BranchName({ row, base, isDefault }: { row: BranchRowData; base: string; isDefault: boolean }) {
118+ return (
119+ <span className="flex min-w-0 items-center gap-1">
120+ {row.deleted ? (
121+ <code className="truncate rounded-md bg-raised px-1.5 py-0.5 font-mono text-[0.8125rem] text-faint line-through">{row.name}</code>
122+ ) : (
123+ <Link
124+ to={`${base}/tree/${encodeURIComponent(row.name)}`}
125+ className="min-w-0 truncate rounded-md bg-accent/10 px-1.5 py-0.5 font-mono text-[0.8125rem] font-medium text-accent hover:bg-accent/20"
126+ >
127+ {row.name}
128+ </Link>
129+ )}
130+ {!row.deleted && <CopyButton text={row.name} label="Copy the branch name" />}
131+ {isDefault && <Badge className="ml-1">default</Badge>}
132+ {row.preview && !row.deleted && (
133+ <Hint label={`Preview at ${host(row.preview)}`}>
134+ <a href={row.preview} aria-label={`Preview at ${host(row.preview)}`} className="ml-1 inline-flex rounded p-0.5 text-accent hover:bg-raised">
135+ <Globe size={13} />
136+ </a>
137+ </Hint>
138+ )}
139+ </span>
140+ );
141+}
142+
143+/** Who made the latest commit, and when, linked to it. */
144+function Updated({ row, base }: { row: BranchRowData; base: string }) {
145+ if (!row.commit) return <span className="text-faint">—</span>;
146+ return (
147+ <Hint label={row.commit.message}>
148+ <Link to={`${base}/commit/${row.commit.hash}`} className="inline-flex items-center gap-1.5 whitespace-nowrap text-muted hover:text-fg">
149+ <CommitAvatars commit={row.commit} size={18} max={2} />
150+ <TimeAgo at={row.commit.at} />
151+ </Link>
152+ </Hint>
153+ );
154+}
155+
156+function driftWords(drift: NonNullable<ActiveBranch["drift"]>, main: string): string {
157+ return `${drift.behind} ${drift.behind === 1 ? "commit" : "commits"} behind ${main}, ${drift.ahead} ahead`;
158+}
159+
160+/** Behind on the left, ahead on the right, each with a bar against the longest shown. */
161+function DriftBars({ drift, scale, main }: { drift: ActiveBranch["drift"]; scale: number; main: string }) {
162+ if (!drift) return <span className="text-faint">—</span>;
163+ const width = (count: number) => `${count === 0 ? 0 : Math.max(6, Math.round((count / scale) * 100))}%`;
164+ return (
165+ <Hint label={driftWords(drift, main)}>
166+ <span className="inline-flex w-28 flex-col gap-1" tabIndex={0}>
167+ <span className="sr-only">{driftWords(drift, main)}</span>
168+ <span className="grid grid-cols-2 font-mono text-xs tabular-nums" aria-hidden>
169+ <span className="border-r border-line pr-1.5 text-right text-muted">{drift.behind}</span>
170+ <span className="pl-1.5 text-muted">{drift.ahead}</span>
171+ </span>
172+ <span className="grid h-1 grid-cols-2" aria-hidden>
173+ <span className="flex justify-end border-r border-line">
174+ <span className="h-full rounded-l-full bg-faint/60" style={{ width: width(drift.behind) }} />
175+ </span>
176+ <span className="flex">
177+ <span className="h-full rounded-r-full bg-accent/70" style={{ width: width(drift.ahead) }} />
178+ </span>
179+ </span>
180+ </span>
181+ </Hint>
182+ );
183+}
184+
185+const PULL_STATE = { open: "Open", draft: "Draft", merged: "Merged", closed: "Closed" } as const;
186+
187+/** The branch's pull request: its state and number, linked. */
188+function PullLink({ pull, base }: { pull: ActiveBranch["pull"]; base: string }) {
189+ if (!pull) return null;
190+ return (
191+ <Hint label={pull.title}>
192+ <Link
193+ to={`${base}/pull/${pull.number}`}
194+ className="inline-flex items-center gap-1 rounded-md border border-line px-1.5 py-0.5 text-xs whitespace-nowrap text-muted hover:border-line-strong hover:text-fg"
195+ >
196+ <PullIcon status={pull.status} size={13} />#{pull.number}
197+ <span className="sr-only">
198+ {PULL_STATE[pull.status]}: {pull.title}
199+ </span>
200+ </Link>
201+ </Hint>
202+ );
203+}
204+
205+/**
206+ * A fetcher for one row's actions, calling `done` once with each answer
207+ * that comes back. It is called as soon as the answer is in, while the
208+ * page reloads: a deleted branch's row is gone once it has, and with it
209+ * this fetcher.
210+ */
211+function useBranchAction(done: (data: BranchActionData) => void) {
212+ const fetcher = useFetcher<BranchActionData>();
213+ const handled = useRef<BranchActionData | undefined>(undefined);
214+ const latest = useRef(done);
215+ latest.current = done;
216+ useEffect(() => {
217+ if (fetcher.state === "submitting" || !fetcher.data || handled.current === fetcher.data) return;
218+ handled.current = fetcher.data;
219+ latest.current(fetcher.data);
220+ }, [fetcher.state, fetcher.data]);
221+ return fetcher;
222+}
223+
224+/** Renaming a branch, with what it does to pull requests, addresses and clones. */
225+function RenameDialog({
226+ row,
227+ main,
228+ open,
229+ onOpenChange,
230+}: {
231+ row: BranchRowData;
232+ main: string;
233+ open: boolean;
234+ onOpenChange: (open: boolean) => void;
235+}) {
236+ const [to, setTo] = useState(row.name);
237+ const [error, setError] = useState<string | null>(null);
238+ const fetcher = useBranchAction((data) => {
239+ if (data.error) setError(data.error);
240+ else onOpenChange(false);
241+ });
242+ const busy = fetcher.state !== "idle";
243+ const next = to.trim();
244+ const isDefault = row.name === main;
245+ const openPull = row.pull && (row.pull.status === "open" || row.pull.status === "draft") ? row.pull : null;
246+ return (
247+ <Dialog
248+ open={open}
249+ onOpenChange={(now) => {
250+ if (now) {
251+ setTo(row.name);
252+ setError(null);
253+ }
254+ onOpenChange(now);
255+ }}
256+ >
257+ <DialogContent>
258+ <fetcher.Form method="post" className="grid gap-4">
259+ <DialogHeader>
260+ <DialogTitle>Rename {row.name}</DialogTitle>
261+ <DialogDescription>
262+ {openPull ? (
263+ <>
264+ Pull request #{openPull.number} from it follows it to the new name, and stays open.{" "}
265+ </>
266+ ) : (
267+ <>Open pull requests from it follow it to the new name. </>
268+ )}
269+ Addresses that name {row.name} redirect to the new name until a branch called {row.name} is made again.
270+ {isDefault && " It stays the default branch, and pull requests into it merge into the new name."}
271+ </DialogDescription>
272+ </DialogHeader>
273+ <input type="hidden" name="intent" value="rename" />
274+ <input type="hidden" name="branch" value={row.name} />
275+ <Field>
276+ <FieldLabel htmlFor={`rename-${row.name}`}>New name</FieldLabel>
277+ <Input
278+ id={`rename-${row.name}`}
279+ name="to"
280+ value={to}
281+ onChange={(event) => setTo(event.target.value)}
282+ maxLength={200}
283+ spellCheck={false}
284+ autoCapitalize="off"
285+ className="font-mono"
286+ aria-invalid={error ? true : undefined}
287+ autoFocus
288+ />
289+ {error && <FieldError>{error}</FieldError>}
290+ </Field>
291+ <div className="grid gap-1.5">
292+ <p className="text-xs text-muted">Clones keep the old name until they catch up:</p>
293+ <CopyLine
294+ text={`git branch -m ${row.name} ${next || "<new-name>"} && git fetch origin && git branch -u origin/${next || "<new-name>"} ${next || "<new-name>"}`}
295+ />
296+ </div>
297+ <DialogFooter>
298+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
299+ Cancel
300+ </Button>
301+ <Button type="submit" disabled={busy || next === "" || next === row.name}>
302+ {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden />}
303+ Rename branch
304+ </Button>
305+ </DialogFooter>
306+ </fetcher.Form>
307+ </DialogContent>
308+ </Dialog>
309+ );
310+}
311+
312+/** The ⋯ menu of a row: a pull request, its activity, its rules, renaming it. */
313+function RowMenu({ row, context, onRename }: { row: BranchRowData; context: BranchListContext; onRename: () => void }) {
314+ const { base, main, canPush, canRenameDefault, canViewRules } = context;
315+ const isDefault = row.name === main;
316+ const canRename = isDefault ? canRenameDefault : canPush;
317+ return (
318+ <DropdownMenu>
319+ <Hint label="More">
320+ <DropdownMenuTrigger asChild>
321+ <Button type="button" variant="ghost" size="icon-xs" aria-label={`More for ${row.name}`}>
322+ <MoreHorizontal size={15} />
323+ </Button>
324+ </DropdownMenuTrigger>
325+ </Hint>
326+ <DropdownMenuContent align="end">
327+ {!isDefault && (
328+ <DropdownMenuItem asChild>
329+ <Link to={`${base}/compare/${refPath(main)}...${refPath(row.name)}`}>
330+ <GitPullRequestArrow />
331+ New pull request
332+ </Link>
333+ </DropdownMenuItem>
334+ )}
335+ <DropdownMenuItem asChild>
336+ <Link to={`${base}/activity?branch=${encodeURIComponent(row.name)}`}>
337+ <Activity />
338+ Activity
339+ </Link>
340+ </DropdownMenuItem>
341+ {canViewRules && (
342+ <DropdownMenuItem asChild>
343+ <Link to={`${base}/settings/rules?branch=${encodeURIComponent(row.name)}`}>
344+ <ShieldCheck />
345+ View rules
346+ </Link>
347+ </DropdownMenuItem>
348+ )}
349+ {canRename && (
350+ <>
351+ <DropdownMenuSeparator />
352+ <DropdownMenuItem onSelect={onRename}>
353+ <PenLine />
354+ Rename branch
355+ </DropdownMenuItem>
356+ </>
357+ )}
358+ </DropdownMenuContent>
359+ </DropdownMenu>
360+ );
361+}
362+
363+/** The trash button; branches with work the default branch lacks ask first. */
364+function DeleteButton({ row, context }: { row: BranchRowData; context: BranchListContext }) {
365+ const [asking, setAsking] = useState(false);
366+ const fetcher = useBranchAction((data) => {
367+ if (data.error) context.onError(data.error);
368+ else context.onDeleted(row);
369+ });
370+ const busy = fetcher.state !== "idle";
371+ const remove = () => fetcher.submit({ intent: "delete", branch: row.name, head: row.head }, { method: "post" });
372+ const ahead = row.drift?.ahead ?? null;
373+ const openPull = row.pull && (row.pull.status === "open" || row.pull.status === "draft") ? row.pull : null;
374+ return (
375+ <>
376+ <Hint label="Delete branch">
377+ <Button
378+ type="button"
379+ variant="ghost"
380+ size="icon-xs"
381+ aria-label={`Delete ${row.name}`}
382+ disabled={busy}
383+ className="hover:text-danger"
384+ onClick={() => (isMerged(row) ? remove() : setAsking(true))}
385+ >
386+ {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Trash2 size={14} />}
387+ </Button>
388+ </Hint>
389+ <AlertDialog open={asking} onOpenChange={setAsking}>
390+ <AlertDialogContent>
391+ <AlertDialogHeader>
392+ <AlertDialogTitle>Delete {row.name}?</AlertDialogTitle>
393+ <AlertDialogDescription>
394+ {ahead == null
395+ ? `It may have commits that are not on ${context.main}.`
396+ : `It has ${ahead} ${ahead === 1 ? "commit" : "commits"} that ${ahead === 1 ? "is" : "are"} not on ${context.main}.`}{" "}
397+ {openPull && `Pull request #${openPull.number} from it cannot be updated or merged while it is gone. `}
398+ You can restore it from this page until you leave it.
399+ </AlertDialogDescription>
400+ </AlertDialogHeader>
401+ <AlertDialogFooter>
402+ <AlertDialogCancel>Cancel</AlertDialogCancel>
403+ <AlertDialogAction
404+ className="border border-danger/40 bg-transparent text-danger hover:border-danger hover:bg-danger/10"
405+ onClick={remove}
406+ >
407+ Delete branch
408+ </AlertDialogAction>
409+ </AlertDialogFooter>
410+ </AlertDialogContent>
411+ </AlertDialog>
412+ </>
413+ );
414+}
415+
416+/** Making a deleted branch again at the commit it pointed to. */
417+function RestoreButton({ row, context }: { row: BranchRowData; context: BranchListContext }) {
418+ const fetcher = useBranchAction((data) => {
419+ if (data.error) context.onError(data.error);
420+ else context.onRestored(row.name);
421+ });
422+ const busy = fetcher.state !== "idle";
423+ return (
424+ <Button
425+ type="button"
426+ variant="outline"
427+ size="xs"
428+ disabled={busy}
429+ onClick={() => fetcher.submit({ intent: "restore", branch: row.name, head: row.head }, { method: "post" })}
430+ >
431+ {busy ? <LoaderCircle size={13} className="animate-spin" /> : <Undo2 size={13} />}
432+ Restore
433+ </Button>
434+ );
435+}
436+
437+/** A row's buttons: restore once deleted; otherwise delete and the menu. */
438+function RowActions({ row, context }: { row: BranchRowData; context: BranchListContext }) {
439+ const [renaming, setRenaming] = useState(false);
440+ if (row.deleted) return context.canPush ? <RestoreButton row={row} context={context} /> : null;
441+ const isDefault = row.name === context.main;
442+ return (
443+ <span className="flex items-center justify-end gap-0.5">
444+ {context.canPush && !isDefault && <DeleteButton row={row} context={context} />}
445+ <RowMenu row={row} context={context} onRename={() => setRenaming(true)} />
446+ {renaming && <RenameDialog row={row} main={context.main} open={renaming} onOpenChange={setRenaming} />}
447+ </span>
448+ );
449+}
450+
451+/** One branch as a table row; below `md`, its details fold under the name. */
452+function BranchRow({ row, context }: { row: BranchRowData; context: BranchListContext }) {
453+ const { base, main, checks, scale } = context;
454+ const isDefault = row.name === main;
455+ const sha = row.commit?.hash ?? row.head;
456+ return (
457+ <TableRow className={row.deleted ? "group/row bg-raised/30" : "group/row hover:bg-raised/30"}>
458+ <TableCell className="max-w-0 py-2.5 align-middle md:w-[40%]">
459+ <BranchName row={row} base={base} isDefault={isDefault} />
460+ {/* What the other columns say, for a narrow screen. */}
461+ <span className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs md:hidden">
462+ {row.deleted ? (
463+ <Badge tone="danger">Deleted</Badge>
464+ ) : (
465+ <>
466+ <Updated row={row} base={base} />
467+ {sha && <CommitChecksBadge checks={checks} sha={sha} className="size-5" />}
468+ {row.drift && !isDefault && (
469+ <span className="font-mono text-muted tabular-nums">
470+ <span className="sr-only">{driftWords(row.drift, main)}</span>
471+ <span aria-hidden>
472+ ↓{row.drift.behind} ↑{row.drift.ahead}
473+ </span>
474+ </span>
475+ )}
476+ <PullLink pull={row.pull} base={base} />
477+ </>
478+ )}
479+ </span>
480+ </TableCell>
481+ {row.deleted ? (
482+ <TableCell colSpan={4} className="align-middle max-md:hidden">
483+ <Badge tone="danger">Deleted</Badge>
484+ </TableCell>
485+ ) : (
486+ <>
487+ <TableCell className="align-middle max-md:hidden">
488+ <Updated row={row} base={base} />
489+ </TableCell>
490+ <TableCell className="align-middle max-md:hidden">
491+ {sha ? <CommitChecksBadge checks={checks} sha={sha} className="size-6" /> : null}
492+ </TableCell>
493+ <TableCell className="align-middle max-md:hidden">
494+ {isDefault ? <span className="text-xs text-faint">Default</span> : <DriftBars drift={row.drift} scale={scale} main={main} />}
495+ </TableCell>
496+ <TableCell className="align-middle max-md:hidden">
497+ <PullLink pull={row.pull} base={base} />
498+ </TableCell>
499+ </>
500+ )}
501+ <TableCell className="w-px py-2 pr-2 align-middle whitespace-nowrap">
502+ <RowActions row={row} context={context} />
503+ </TableCell>
504+ </TableRow>
505+ );
506+}
507+
508+/** A section's branches as a table under the column names. */
509+export function BranchTable({ rows, context, label }: { rows: BranchRowData[]; context: BranchListContext; label: string }) {
510+ return (
511+ <div className="overflow-hidden rounded-lg border border-line">
512+ <Table aria-label={label}>
513+ <TableHeader>
514+ <TableRow>
515+ <TableHead>Branch</TableHead>
516+ <TableHead className="max-md:hidden">Updated</TableHead>
517+ <TableHead className="max-md:hidden">Check status</TableHead>
518+ <TableHead className="max-md:hidden">
519+ <span className="inline-grid w-28 grid-cols-2">
520+ <span className="border-r border-line pr-1.5 text-right">Behind</span>
521+ <span className="pl-1.5">Ahead</span>
522+ </span>
523+ </TableHead>
524+ <TableHead className="max-md:hidden">Pull request</TableHead>
525+ <TableHead className="w-px">
526+ <span className="sr-only">Actions</span>
527+ </TableHead>
528+ </TableRow>
529+ </TableHeader>
530+ <TableBody>
531+ {rows.map((row) => (
532+ <BranchRow key={row.name} row={row} context={context} />
533+ ))}
534+ </TableBody>
535+ </Table>
536+ </div>
537+ );
538+}
539+
540+/** Making a branch: its name, and the branch, tag or commit it starts at. */
541+export function NewBranchDialog({
542+ branches,
543+ main,
544+ onMade,
545+}: {
546+ /** Every branch's name, the default first. */
547+ branches: string[];
548+ main: string;
549+ onMade: (name: string) => void;
550+}) {
551+ const [open, setOpen] = useState(false);
552+ const [name, setName] = useState("");
553+ const [from, setFrom] = useState(main);
554+ const [byCommit, setByCommit] = useState(false);
555+ const [commit, setCommit] = useState("");
556+ const [error, setError] = useState<string | null>(null);
557+ const fetcher = useBranchAction((data) => {
558+ if (data.error) return setError(data.error);
559+ setOpen(false);
560+ onMade(data.branch ?? name.trim());
561+ });
562+ const busy = fetcher.state !== "idle";
563+ const source = byCommit ? commit.trim() : from;
564+ return (
565+ <Dialog
566+ open={open}
567+ onOpenChange={(now) => {
568+ if (now) {
569+ setName("");
570+ setFrom(main);
571+ setByCommit(false);
572+ setCommit("");
573+ setError(null);
574+ }
575+ setOpen(now);
576+ }}
577+ >
578+ <Button type="button" size="sm" onClick={() => setOpen(true)}>
579+ <GitBranchPlus size={15} />
580+ New branch
581+ </Button>
582+ <DialogContent>
583+ <fetcher.Form method="post" className="grid gap-4">
584+ <DialogHeader>
585+ <DialogTitle>Create a branch</DialogTitle>
586+ <DialogDescription>It starts at the same commit as the branch, tag or commit you pick. Push to it, or open a pull request from it.</DialogDescription>
587+ </DialogHeader>
588+ <input type="hidden" name="intent" value="create" />
589+ <input type="hidden" name="from" value={source} />
590+ <Field>
591+ <FieldLabel htmlFor="new-branch-name">Name</FieldLabel>
592+ <Input
593+ id="new-branch-name"
594+ name="branch"
595+ value={name}
596+ onChange={(event) => setName(event.target.value)}
597+ placeholder="feature/sign-in"
598+ maxLength={200}
599+ spellCheck={false}
600+ autoCapitalize="off"
601+ className="font-mono placeholder:font-sans"
602+ aria-invalid={error ? true : undefined}
603+ autoFocus
604+ />
605+ {error && <FieldError>{error}</FieldError>}
606+ </Field>
607+ <Field>
608+ <FieldLabel htmlFor="new-branch-from">{byCommit ? "Tag or commit" : "Start from"}</FieldLabel>
609+ {byCommit ? (
610+ <Input
611+ id="new-branch-from"
612+ value={commit}
613+ onChange={(event) => setCommit(event.target.value)}
614+ placeholder="v1.2.0 or 9f2c4e1"
615+ spellCheck={false}
616+ autoCapitalize="off"
617+ className="font-mono placeholder:font-sans"
618+ />
619+ ) : (
620+ <Combobox
621+ id="new-branch-from"
622+ aria-label="Branch to start from"
623+ value={from}
624+ onValueChange={setFrom}
625+ searchPlaceholder="Find a branch…"
626+ emptyText="No branch matches."
627+ className="font-mono"
628+ options={branches.map((branch) => ({ value: branch, label: branch, icon: <GitBranch /> }))}
629+ />
630+ )}
631+ <FieldDescription>
632+ <Button type="button" variant="link" size="inline" className="text-xs" onClick={() => setByCommit(!byCommit)}>
633+ {byCommit ? "Start from a branch instead" : "Start from a tag or commit instead"}
634+ </Button>
635+ </FieldDescription>
636+ </Field>
637+ <DialogFooter>
638+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
639+ Cancel
640+ </Button>
641+ <Button type="submit" disabled={busy || name.trim() === "" || source === ""}>
642+ {busy && <LoaderCircle size={14} className="animate-spin" aria-hidden />}
643+ Create branch
644+ </Button>
645+ </DialogFooter>
646+ </fetcher.Form>
647+ </DialogContent>
648+ </Dialog>
649+ );
650+}
651+
652+// --- The default branch's protection hint, dismissed per person and repository ---
653+
654+const listeners = new Set<() => void>();
655+const subscribe = (listener: () => void) => {
656+ listeners.add(listener);
657+ return () => listeners.delete(listener);
658+};
659+const storage = () => (typeof window === "undefined" ? null : window.localStorage);
660+
661+/**
662+ * That the default branch has no rules, for people who can add them, until
663+ * they dismiss it: remembered by this browser for them and this repository.
664+ */
665+export function ProtectCallout({ base, main, dismissKey }: { base: string; main: string; dismissKey: string }) {
666+ const hidden = useSyncExternalStore(
667+ subscribe,
668+ useCallback(() => {
669+ try {
670+ return storage()?.getItem(dismissKey) === "1";
671+ } catch {
672+ return false;
673+ }
674+ }, [dismissKey]),
675+ () => false,
676+ );
677+ if (hidden) return null;
678+ const dismiss = () => {
679+ try {
680+ storage()?.setItem(dismissKey, "1");
681+ } catch {
682+ // Not remembered: it hides until the page is opened again.
683+ }
684+ for (const listener of listeners) listener();
685+ };
686+ return (
687+ <Alert tone="warn" className="flex flex-wrap items-start gap-3 px-4 py-3">
688+ <ShieldAlert size={16} className="mt-0.5 shrink-0 text-warn" />
689+ <div className="min-w-0 grow basis-60">
690+ <AlertTitle>{main} isn’t protected</AlertTitle>
691+ <AlertDescription>
692+ Anyone who can push can push straight to it or force-push over it. Add a ruleset to require pull requests and passing checks before anything lands on it.
693+ </AlertDescription>
694+ </div>
695+ <div className="flex shrink-0 items-center gap-2">
696+ <Button asChild size="xs" variant="outline">
697+ <Link to={`${base}/settings/rules/new`}>Protect this branch</Link>
698+ </Button>
699+ <Hint label="Dismiss">
700+ <Button type="button" variant="ghost" size="icon-xs" aria-label="Dismiss" onClick={dismiss}>
701+ <X size={14} />
702+ </Button>
703+ </Hint>
704+ </div>
705+ </Alert>
706+ );
707+}
+4−1
1919
2020 export type ActiveBranch = {
2121 name: string;
22+ /** The commit it points to. */
23+ head: string;
2224 commit: { hash: string; message: string; author: CommitPerson; coAuthors: CommitPerson[]; at: string } | null;
2325 /** Null when the two histories were not read far enough to meet. */
2426 drift: Drift | null;
25− pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean } | null;
27+ /** The open pull request from it, else the newest merged or closed one. */
28+ pull: { number: number; title: string; checkStatus: Pull["checkStatus"]; draft: boolean; status: Pull["status"] } | null;
2629 preview: string | null;
2730 };
2831
+17−1
33
44 import type { G1tEvent } from "@g1t/contracts";
55
6−import { activityLine, alignWeeks, count, languageBar, licenseLabel, peak } from "./about.ts";
6+import { aboutBranch, activityLine, alignWeeks, count, languageBar, licenseLabel, peak } from "./about.ts";
77
88 test("a license is named by its SPDX id, or not at all", () => {
99 assert.equal(licenseLabel({ spdxId: "MIT", name: "MIT License", path: "LICENSE" }), "MIT license");
7171 assert.equal(activityLine(event("repo.default_branch_changed", { from: "master", to: "main", renamed: true })), null);
7272 assert.equal(activityLine(event("issue.opened", { number: 1 })), null);
7373 });
74+
75+test("a deleted branch is activity with the commit that restores it; a deleted tag is not", () => {
76+ assert.deepEqual(activityLine(event("git.deleted", { ref: "refs/heads/feature/x", before: "abc" })), { kind: "deleted", branch: "feature/x", commit: "abc" });
77+ assert.equal(activityLine(event("git.deleted", { ref: "refs/tags/v1", before: "abc" })), null);
78+});
79+
80+test("activity is about a branch when it pushed, deleted, renamed or merged into it", () => {
81+ const push = { kind: "push", branch: "feat", commit: "b", before: "a", created: false, defaultBranch: false } as const;
82+ assert.equal(aboutBranch(push, "feat", "main"), true);
83+ assert.equal(aboutBranch(push, "main", "main"), false);
84+ assert.equal(aboutBranch({ kind: "deleted", branch: "feat", commit: "b" }, "feat", "main"), true);
85+ assert.equal(aboutBranch({ kind: "renamed", from: "old", to: "feat" }, "old", "main"), true);
86+ assert.equal(aboutBranch({ kind: "merge", number: 1, commit: "c" }, "main", "main"), true);
87+ assert.equal(aboutBranch({ kind: "merge", number: 1, commit: "c" }, "feat", "main"), false);
88+ assert.equal(aboutBranch({ kind: "tag", tag: "v1", commit: "c" }, "feat", "main"), false);
89+});
+29−1
5858 /** An Activity line: what happened, and the branch, tag or pull request it was to. */
5959 export type ActivityLine =
6060 | { kind: "push"; branch: string; commit: string; before: string | null; created: boolean; defaultBranch: boolean }
61+ | { kind: "deleted"; branch: string; commit: string }
6162 | { kind: "tag"; tag: string; commit: string }
6263 | { kind: "merge"; number: number; commit: string }
6364 | { kind: "renamed"; from: string; to: string }
6465 | { kind: "default"; from: string; to: string };
6566
6667 /** The events the Activity page lists. */
67−export const ACTIVITY_TYPES = ["git.push", "pull.merged", "branch.renamed", "repo.default_branch_changed"] as const;
68+export const ACTIVITY_TYPES = ["git.push", "git.deleted", "pull.merged", "branch.renamed", "repo.default_branch_changed"] as const;
6869
6970 /** One event as an Activity line, or null for one it does not list. */
7071 export function activityLine(event: G1tEvent): ActivityLine | null {
8283 defaultBranch: data.defaultBranch,
8384 };
8485 }
86+ case "git.deleted": {
87+ // A deleted tag is not listed; a deleted branch is, with the commit
88+ // it pointed to, which restores it.
89+ const data = event.data as { ref: string; before: string };
90+ if (!data.ref.startsWith("refs/heads/")) return null;
91+ return { kind: "deleted", branch: data.ref.slice("refs/heads/".length), commit: data.before };
92+ }
8593 case "pull.merged": {
8694 const data = event.data as { number: number; commit: string };
8795 return { kind: "merge", number: data.number, commit: data.commit };
99107 return null;
100108 }
101109 }
110+
111+/**
112+ * Whether an Activity line is about `branch`: pushed to, made, deleted or
113+ * renamed, or made or replaced as the default. Merges land on the default
114+ * branch (`defaultBranch`).
115+ */
116+export function aboutBranch(line: ActivityLine, branch: string, defaultBranch: string): boolean {
117+ switch (line.kind) {
118+ case "push":
119+ case "deleted":
120+ return line.branch === branch;
121+ case "tag":
122+ return false;
123+ case "merge":
124+ return branch === defaultBranch;
125+ case "renamed":
126+ case "default":
127+ return line.from === branch || line.to === branch;
128+ }
129+}
+92−2
33
44 import type { BranchDrifts, Commit } from "@g1t/contracts";
55
6−import { activeBranches, branchesToRead } from "./branches.ts";
6+import { activeBranches, branchSections, branchTab, branchesToRead, driftScale, isMerged, isStale, isYours } from "./branches.ts";
77
88 const commit = (hash: string, at: string, message = `${hash}\n\nbody`): Commit => ({
99 hash,
4747 assert.deepEqual(shown.map((b) => b.name), ["idea", "fix", "old"]);
4848 assert.deepEqual(shown[1], {
4949 name: "fix",
50+ head: "f2",
5051 // No accounts given: the name on the commit, and never its address.
5152 commit: { hash: "f2", message: "f2", author: { kind: "author", name: "Ada", username: null, avatar: null }, coAuthors: [], at: "2026-10-07T00:00:00Z" },
5253 drift: { ahead: 2, behind: 1 },
53− pull: { number: 7, title: "Fix it", checkStatus: "passed", draft: false },
54+ pull: { number: 7, title: "Fix it", checkStatus: "passed", draft: false, status: "open" },
5455 preview: "https://fix.g1t.page",
5556 });
5657 assert.equal(shown[2]?.drift, null);
102103 at: "2026-10-02T07:52:16.000Z",
103104 });
104105 });
106+
107+test("a branch's pull request is the open one, else the newest merged or closed", () => {
108+ const reading = [
109+ { name: "fix", hash: "f2" },
110+ { name: "done", hash: "d1" },
111+ ];
112+ const newestFirst = [
113+ { branch: "done", number: 9, title: "Second try", checkStatus: null, status: "merged" as const },
114+ { branch: "fix", number: 8, title: "Closed", checkStatus: null, status: "closed" as const },
115+ { branch: "done", number: 5, title: "First try", checkStatus: null, status: "closed" as const },
116+ { branch: "fix", number: 7, title: "Fix it", checkStatus: "passed" as const, status: "open" as const },
117+ ];
118+ const shown = activeBranches(reading, null, { pulls: newestFirst, previews: [] });
119+ assert.deepEqual(
120+ shown.map((b) => [b.name, b.pull?.number, b.pull?.status]),
121+ [
122+ ["fix", 7, "open"],
123+ ["done", 9, "merged"],
124+ ],
125+ );
126+ // Only an open pull request puts a branch first in line to be read.
127+ const all = [
128+ { name: "main", hash: "m" },
129+ { name: "a", hash: "a" },
130+ { name: "done", hash: "d1" },
131+ ];
132+ assert.deepEqual(branchesToRead({ defaultBranch: "main", branches: all, pulls: newestFirst }, 1).reading.map((b) => b.name), ["a"]);
133+});
134+
135+const now = Date.parse("2026-10-11T00:00:00Z");
136+const row = (name: string, at: string | null, username: string | null = null, drift: { ahead: number; behind: number } | null = null) => ({
137+ name,
138+ head: name,
139+ commit: at ? { hash: name, message: name, author: { kind: "user" as const, name, username, avatar: null }, coAuthors: [], at } : null,
140+ drift,
141+ pull: null,
142+ preview: null,
143+});
144+
145+test("a tab comes from the address, the overview otherwise", () => {
146+ assert.equal(branchTab("stale"), "stale");
147+ assert.equal(branchTab("STALE"), "overview");
148+ assert.equal(branchTab(null), "overview");
149+});
150+
151+test("stale is no commits in 90 days; yours is the viewer's latest commit", () => {
152+ assert.equal(isStale(row("old", "2026-07-01T00:00:00Z"), now), true);
153+ assert.equal(isStale(row("new", "2026-09-01T00:00:00Z"), now), false);
154+ assert.equal(isStale(row("unread", null), now), false);
155+ assert.equal(isYours(row("mine", "2026-10-01T00:00:00Z", "Ada"), "ada"), true);
156+ assert.equal(isYours(row("theirs", "2026-10-01T00:00:00Z", "grace"), "ada"), false);
157+ assert.equal(isYours(row("nobody", "2026-10-01T00:00:00Z", null), null), false);
158+});
159+
160+test("a branch with nothing the default branch lacks, or a merged pull request, is merged", () => {
161+ assert.equal(isMerged({ drift: { ahead: 0, behind: 3 }, pull: null }), true);
162+ assert.equal(isMerged({ drift: { ahead: 2, behind: 0 }, pull: null }), false);
163+ assert.equal(isMerged({ drift: null, pull: null }), false);
164+ assert.equal(isMerged({ drift: { ahead: 2, behind: 0 }, pull: { number: 1, title: "", checkStatus: null, draft: false, status: "merged" } }), true);
165+});
166+
167+test("the overview shows a few of yours, the active and the stale ones; each tab lists its own", () => {
168+ const rows = [
169+ row("a", "2026-10-10T00:00:00Z", "ada"),
170+ row("b", "2026-10-09T00:00:00Z", "grace"),
171+ row("c", "2026-10-08T00:00:00Z", "ada"),
172+ row("d", "2026-01-01T00:00:00Z", "ada"),
173+ ];
174+ const viewer = { username: "ada", now };
175+ assert.deepEqual(
176+ branchSections(rows, "overview", viewer, 2).map((s) => [s.tab, s.branches.map((b) => b.name), s.more]),
177+ [
178+ ["yours", ["a", "c"], 1],
179+ ["active", ["a", "b"], 1],
180+ ["stale", ["d"], 0],
181+ ],
182+ );
183+ // Someone with no branches of their own sees no section of theirs.
184+ assert.deepEqual(branchSections(rows, "overview", { username: "linus", now }).map((s) => s.tab), ["active", "stale"]);
185+ assert.deepEqual(branchSections(rows, "stale", viewer)[0]?.branches.map((b) => b.name), ["d"]);
186+ assert.deepEqual(branchSections(rows, "active", viewer)[0]?.branches.map((b) => b.name), ["a", "b", "c"]);
187+ assert.deepEqual(branchSections(rows, "yours", viewer)[0]?.branches.map((b) => b.name), ["a", "c", "d"]);
188+ assert.equal(branchSections(rows, "all", viewer)[0]?.branches.length, 4);
189+});
190+
191+test("the bars are drawn against the longest count shown", () => {
192+ assert.equal(driftScale([row("a", null, null, { ahead: 3, behind: 40 }), row("b", null, null, { ahead: 12, behind: 0 })]), 40);
193+ assert.equal(driftScale([row("a", null)]), 1);
194+});
+119−8
11 /**
2− * Active branches as the overview and the Branches page show them, from
3− * the repos service's `branch_drift` answer. The counting itself is done
2+ * Branches as the overview and the Branches page show them, from the
3+ * repos service's `branch_drift` answer. The counting itself is done
44 * there (services/repos/src/drift.rs), kept by the pair of head commits.
55 */
66 import type { Branch, BranchDrifts, Commit, Pull } from "@g1t/contracts";
1818
1919 type Preview = { branch?: string | null; number?: number | null; url: string };
2020
21+type PullOn = Pick<Pull, "branch" | "number" | "title" | "checkStatus" | "status">;
22+
2123 /** A commit's people, as lib/commit-people.ts `showCommit` finds them. */
2224 export type People = (commit: Commit) => Pick<ShownCommit, "author" | "coAuthors">;
2325
2729 coAuthors: [],
2830 });
2931
32+/** Whether a pull request is still open: ready for review or a draft. */
33+const isOpen = (pull: Pick<Pull, "status">) => pull.status === "open" || pull.status === "draft";
34+
3035 /**
3136 * A commit as a branch row shows it: its first line, and its people as
3237 * `people` finds them (lib/branches.server.ts passes the accounts).
4348 * branch's head commit.
4449 */
4550 export function branchesToRead(
46− input: { defaultBranch: string; branches: Branch[]; pulls: Pick<Pull, "branch">[] },
51+ input: { defaultBranch: string; branches: Branch[]; pulls: Pick<Pull, "branch" | "status">[] },
4752 read: number,
4853 ): { reading: Branch[]; total: number; mainHead: string | null } {
49− const pullOn = new Set(input.pulls.flatMap((pull) => (pull.branch ? [pull.branch] : [])));
54+ const pullOn = new Set(input.pulls.flatMap((pull) => (pull.branch && isOpen(pull) ? [pull.branch] : [])));
5055 const others = input.branches.filter((branch) => branch.name !== input.defaultBranch);
5156 const reading = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, read);
5257 const mainHead = input.branches.find((branch) => branch.name === input.defaultBranch)?.hash || null;
5459 }
5560
5661 /**
62+ * Each branch's pull request: the open one, else the newest that was
63+ * merged or closed. `pulls` come newest first, as `listPulls` answers.
64+ */
65+function pullsByBranch(pulls: PullOn[]): Map<string, PullOn> {
66+ const found = new Map<string, PullOn>();
67+ for (const pull of pulls) {
68+ if (!pull.branch) continue;
69+ const kept = found.get(pull.branch);
70+ if (!kept || (!isOpen(kept) && isOpen(pull))) found.set(pull.branch, pull);
71+ }
72+ return found;
73+}
74+
75+/**
5776 * Each branch read, newest commit first, with what repos measured (null
5877 * when that could not be had: no commits, no counts), its pull request and
5978 * its preview.
6180 export function activeBranches(
6281 reading: Branch[],
6382 measured: BranchDrifts | null,
64− input: { pulls: Pick<Pull, "branch" | "number" | "title" | "checkStatus" | "status">[]; previews: Preview[] },
83+ input: { pulls: PullOn[]; previews: Preview[] },
6584 people: People = byName,
6685 ): ActiveBranch[] {
67− const pullOn = new Map(input.pulls.flatMap((pull) => (pull.branch ? [[pull.branch, pull] as const] : [])));
86+ const pullOn = pullsByBranch(input.pulls);
6887 const byHead = new Map((measured?.branches ?? []).map((found) => [found.head, found]));
6988 return reading
7089 .map((branch): ActiveBranch => {
7291 const found = byHead.get(branch.hash);
7392 return {
7493 name: branch.name,
94+ head: branch.hash,
7595 commit: summary(found?.commit, people),
7696 drift: found?.drift ?? null,
77− pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft" } : null,
78− preview: input.previews.find((app) => app.branch === branch.name || (pull != null && app.number === pull.number))?.url ?? null,
97+ pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft", status: pull.status } : null,
98+ preview: input.previews.find((app) => app.branch === branch.name || (pull != null && isOpen(pull) && app.number === pull.number))?.url ?? null,
7999 };
80100 })
81101 .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"));
82102 }
103+
104+// --- The Branches page: its tabs and sections --------------------------------
105+
106+/** The Branches page's tabs, kept in the address as `?tab=`. */
107+export const BRANCH_TABS = ["overview", "yours", "active", "stale", "all"] as const;
108+export type BranchTab = (typeof BRANCH_TABS)[number];
109+
110+/** A tab from the address: the overview for anything else. */
111+export function branchTab(value: string | null | undefined): BranchTab {
112+ return (BRANCH_TABS as readonly string[]).includes(value ?? "") ? (value as BranchTab) : "overview";
113+}
114+
115+/** A branch whose last commit is older than this is stale. */
116+export const STALE_DAYS = 90;
117+
118+/** Whether a branch's last commit is more than `STALE_DAYS` old. A branch whose commit was not read is not. */
119+export function isStale(branch: Pick<ActiveBranch, "commit">, now: number): boolean {
120+ const at = branch.commit ? Date.parse(branch.commit.at) : Number.NaN;
121+ return Number.isFinite(at) && at < now - STALE_DAYS * 86_400_000;
122+}
123+
124+/** Whether `username` made a branch's latest commit. */
125+export function isYours(branch: Pick<ActiveBranch, "commit">, username: string | null | undefined): boolean {
126+ return Boolean(username) && branch.commit?.author.username?.toLowerCase() === username?.toLowerCase();
127+}
128+
129+/**
130+ * Whether deleting a branch loses nothing the default branch lacks: every
131+ * commit on it is on the default branch, or its pull request was merged.
132+ * Such a branch is deleted without asking.
133+ */
134+export function isMerged(branch: Pick<ActiveBranch, "drift" | "pull">): boolean {
135+ return branch.drift?.ahead === 0 || branch.pull?.status === "merged";
136+}
137+
138+export type BranchSection = {
139+ tab: Exclude<BranchTab, "overview">;
140+ title: string;
141+ /** Under the title, when it needs saying. */
142+ note?: string;
143+ branches: ActiveBranch[];
144+ /** How many more the section's own tab lists. */
145+ more: number;
146+};
147+
148+/** How many branches each section of the overview shows. */
149+export const OVERVIEW_SHOWN = 5;
150+
151+/**
152+ * The sections a tab lists, besides the default branch: the overview
153+ * shows a few of the viewer's branches, the active ones and the stale ones; each other
154+ * tab lists its own.
155+ */
156+export function branchSections(
157+ branches: ActiveBranch[],
158+ tab: BranchTab,
159+ viewer: { username: string | null; now: number },
160+ shown = OVERVIEW_SHOWN,
161+): BranchSection[] {
162+ const yours = branches.filter((branch) => isYours(branch, viewer.username));
163+ const active = branches.filter((branch) => !isStale(branch, viewer.now));
164+ const stale = branches.filter((branch) => isStale(branch, viewer.now));
165+ const whole = (section: Omit<BranchSection, "more">): BranchSection => ({ ...section, more: 0 });
166+ switch (tab) {
167+ case "overview": {
168+ const few = (section: Omit<BranchSection, "more">): BranchSection => ({
169+ ...section,
170+ branches: section.branches.slice(0, shown),
171+ more: Math.max(0, section.branches.length - shown),
172+ });
173+ return [
174+ ...(yours.length > 0 ? [few({ tab: "yours", title: "Your branches", branches: yours })] : []),
175+ few({ tab: "active", title: "Active branches", branches: active }),
176+ ...(stale.length > 0 ? [few({ tab: "stale", title: "Stale branches", branches: stale })] : []),
177+ ];
178+ }
179+ case "yours":
180+ return [whole({ tab, title: "Your branches", note: "Branches whose latest commit is yours.", branches: yours })];
181+ case "active":
182+ return [whole({ tab, title: "Active branches", note: `Commits in the last ${STALE_DAYS} days.`, branches: active })];
183+ case "stale":
184+ return [whole({ tab, title: "Stale branches", note: `No commits in the last ${STALE_DAYS} days.`, branches: stale })];
185+ case "all":
186+ return [whole({ tab, title: "All branches", branches })];
187+ }
188+}
189+
190+/** The longest behind or ahead count among `branches`, which a full bar stands for. */
191+export function driftScale(branches: Pick<ActiveBranch, "drift">[]): number {
192+ return Math.max(1, ...branches.map((branch) => Math.max(branch.drift?.ahead ?? 0, branch.drift?.behind ?? 0)));
193+}
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.