Skip to content

Commit

Merge remote-tracking branch 'origin/main' into claude/cool-newton-7nm8r9

# Conflicts: # apps/api/src/operations.rs

Claudeagentcommitted Parents3d58210dc13b76Browse files
57 files+1159−5041 files not counted0/57 viewed
+4−0
125125 Op::UpdateRepo,
126126 Op::RenameRepo,
127127 Op::RenameBranch,
128+ Op::CreateBranch,
129+ Op::DeleteBranch,
128130 Op::SetRepoVisibility,
129131 Op::ArchiveRepo,
130132 Op::UnarchiveRepo,
661663 Op::TransferRepo => "Transfer a repository",
662664 Op::RenameRepo => "Rename a repository",
663665 Op::RenameBranch => "Rename a branch",
666+ Op::CreateBranch => "Create a branch",
667+ Op::DeleteBranch => "Delete a branch",
664668 Op::ArchiveRepo => "Archive a repository",
665669 Op::UnarchiveRepo => "Unarchive a repository",
666670 Op::SetRepoVisibility => "Change a repository's visibility",
+69−1
144144 TransferRepo,
145145 RenameRepo,
146146 RenameBranch,
147+ CreateBranch,
148+ DeleteBranch,
147149 ArchiveRepo,
148150 UnarchiveRepo,
149151 SetRepoVisibility,
714716 }
715717
716718 impl Op {
717− pub const ALL: [Op; 386] = [
719+ pub const ALL: [Op; 388] = [
718720 Op::Whoami,
719721 Op::GetWorkspace,
720722 Op::CreateWorkspace,
746748 Op::TransferRepo,
747749 Op::RenameRepo,
748750 Op::RenameBranch,
751+ Op::CreateBranch,
752+ Op::DeleteBranch,
749753 Op::ArchiveRepo,
750754 Op::UnarchiveRepo,
751755 Op::SetRepoVisibility,
11411145 Op::TransferRepo => "transfer_repo",
11421146 Op::RenameRepo => "rename_repo",
11431147 Op::RenameBranch => "rename_branch",
1148+ Op::CreateBranch => "create_branch",
1149+ Op::DeleteBranch => "delete_branch",
11441150 Op::ArchiveRepo => "archive_repo",
11451151 Op::UnarchiveRepo => "unarchive_repo",
11461152 Op::SetRepoVisibility => "set_repo_visibility",
14071413 Op::RenameBranch => {
14081414 "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."
14091415 }
1416+ Op::CreateBranch => {
1417+ "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."
1418+ }
1419+ Op::DeleteBranch => {
1420+ "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."
1421+ }
14101422 Op::ArchiveRepo => {
14111423 "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."
14121424 }
22222234 }),
22232235 &["repo", "branch", "new_name"],
22242236 ),
2237+ Op::CreateBranch => object(
2238+ json!({
2239+ "repo": repo_schema(),
2240+ "name": {
2241+ "type": "string",
2242+ "description": "The new branch's name, e.g. \"feature/login\".",
2243+ },
2244+ "from": {
2245+ "type": "string",
2246+ "description": "The branch, tag or commit to start it from. The default branch when left out.",
2247+ },
2248+ }),
2249+ &["repo", "name"],
2250+ ),
2251+ Op::DeleteBranch => object(
2252+ json!({
2253+ "repo": repo_schema(),
2254+ "branch": {
2255+ "type": "string",
2256+ "description": "The branch to delete, e.g. \"feature/login\". URL-encode slashes in the path.",
2257+ },
2258+ "head": {
2259+ "type": "string",
2260+ "description": "The commit you saw it at. If it has moved since, it is left alone (409).",
2261+ },
2262+ }),
2263+ &["repo", "branch"],
2264+ ),
22252265 Op::ArchiveRepo | Op::UnarchiveRepo | Op::RestoreRepo => repo_only(),
22262266 Op::SetRepoVisibility => object(
22272267 json!({
42244264 )
42254265 .await
42264266 }
4267+ Op::CreateBranch => {
4268+ pass(
4269+ repos,
4270+ "create_branch",
4271+ &json!({
4272+ "actor": actor(),
4273+ "path": repo,
4274+ "name": text(input, "name"),
4275+ "from": text(input, "from"),
4276+ "surface": services.audit.surface,
4277+ }),
4278+ )
4279+ .await
4280+ }
4281+ Op::DeleteBranch => {
4282+ pass(
4283+ repos,
4284+ "remove_branch",
4285+ &json!({
4286+ "actor": actor(),
4287+ "path": repo,
4288+ "branch": text(input, "branch"),
4289+ "head": optional_text(input, "head"),
4290+ "surface": services.audit.surface,
4291+ }),
4292+ )
4293+ .await
4294+ }
42274295 Op::ArchiveRepo | Op::UnarchiveRepo => {
42284296 pass(
42294297 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
161161 | Op::UnarchiveRepo
162162 | Op::SetRepoVisibility
163163 | Op::RestoreRepo => through::<repos::Repo>(op, sent),
164+ Op::CreateBranch | Op::DeleteBranch => through::<repos::Branch>(op, sent),
164165 Op::DeleteRepo => through::<repos::DeletedRepo>(op, sent),
165166 Op::ListDeletedRepos => through::<Vec<repos::DeletedRepo>>(op, sent),
166167 Op::GetRepoSettings | Op::UpdateRepoSettings => through::<work::RepoSettings>(op, sent),
+2−0
305305 Op::RenameBranch,
306306 &[],
307307 ),
308+ route("POST", "/repos/:owner/:name/branches", Op::CreateBranch, &[]),
309+ route("DELETE", "/repos/:owner/:name/branches/:branch", Op::DeleteBranch, &[("head", "head")]),
308310 route(
309311 "GET",
310312 "/repos/:owner/:name/settings",
+2−0
118118 a("update_release", Op::About(AboutOp::UpdateRelease), "Change a release's title, notes, draft or prerelease"),
119119 a("delete_release", Op::About(AboutOp::DeleteRelease), "Delete a release; its tag stays"),
120120 a("rename_branch", Op::RenameBranch, "Rename a branch"),
121+ a("create_branch", Op::CreateBranch, "Make a branch at a branch, tag or commit"),
122+ a("delete_branch", Op::DeleteBranch, "Delete a branch; make it again at its commit to restore it"),
121123 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
122124 a("transfer", Op::TransferRepo, "Move it to another workspace you own"),
123125 a("mirror", Op::Mirrors(MirrorsOp::GetMirror), "Its remotes: what it mirrors or is mirrored to"),
+13−5
232232 seconds for the page to settle, then takes it as it is.
233233 - The screenshot is of the app's `g1t.page` address. A custom domain
234234 serves the same app, so it looks the same.
235−- Until it is ready, or if it could not be taken, the overview shows a
236− plain frame with the address. A failed screenshot is tried again when
237− the overview is next opened, at most every five minutes.
238−- Only people who can read the project's repository see it, like the
239− rest of its deployments.
235+- Until it is ready, or if it could not be taken, the overview shows the
236+ project's uploaded image, if it has one, or else a plain frame with the
237+ address. A failed screenshot is tried again when the overview is next
238+ opened, at most every five minutes.
239+- Only people with a role on the project's repository see it, like the
240+ rest of its deployments; anyone else who can see the project sees its
241+ uploaded image or the frame.
242+- With **Use the uploaded image instead of a screenshot** on, the
243+ uploaded image shows instead.
240244 - Screenshots are not charged.
241245
246+An app deployed elsewhere gets a screenshot of its production address
247+too, and any project can show an uploaded image instead. See
248+[production's picture](/guides/projects/#productions-picture).
249+
242250 The [checklist on the overview](/guides/projects/#get-to-production) also
243251 tracks the first production deploy, a custom domain and a first preview.
244252
+80−9
176176 git push origin my-change
177177 ```
178178
179+### The Branches page
180+
179181 A repository's **Branches** tab, `g1t.sh/<workspace>/<repo>/branches`, lists
180−every branch: the default one first, then those with a commit in the last 90
181−days (**Active**), then the rest (**Stale**). Each shows its last commit, how
182−many commits it is ahead of and behind the default branch, the pull request
183−open on it with its checks, and its preview when it has one. A branch with
184−no pull request links to opening one; one with nothing the default branch
185−lacks (ahead `0`) says **Nothing to merge** instead. The counts are exact,
186−merges included. g1t reads up to 1,000 commits of each history to find
187−where the two meet; a branch that left the default branch further back
188−than that shows no counts. Search narrows the list by name.
182+its branches under five tabs, kept in the address as `?tab=`:
183+
184+| Tab | What it lists |
185+| --- | --- |
186+| **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. |
187+| **Yours** (`?tab=yours`) | Branches whose latest commit is yours. |
188+| **Active** (`?tab=active`) | Branches with a commit in the last 90 days. |
189+| **Stale** (`?tab=stale`) | Branches with no commit in the last 90 days. |
190+| **All** (`?tab=all`) | Every branch but the default, newest commit first. |
191+
192+**Search branches** narrows any tab by name (`?q=`). Each branch shows:
193+
194+| Column | What it says |
195+| --- | --- |
196+| **Branch** | Its name, linked to its files, with a button that copies it; a globe when it has a preview. |
197+| **Updated** | Who made its latest commit, and when, linked to the commit. |
198+| **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. |
199+| **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. |
200+| **Pull request** | Its open pull request, else its newest merged or closed one, with its state and number. |
201+
202+On a phone, the columns fold under the name. Up to 60 branches are read for
203+a page, those with an open pull request first; search finds the rest.
204+
205+People with the Write role or higher can change branches from the page:
206+
207+- **New branch** makes a branch from another branch, or from a tag or a
208+ commit, which you type in.
209+- The trash button deletes a branch. A branch with commits the default
210+ branch lacks, or whose counts could not be read, asks first; a merged
211+ one (nothing ahead, or its pull request merged) goes at once. It stays in
212+ the list as **Deleted**, with **Restore**, which makes it again at the
213+ same commit, until you leave the page. While it is gone, a pull request
214+ from it can be neither updated nor merged. A deletion is listed in the
215+ repository's **Activity** with the commit it pointed to, so it can still
216+ be restored later from the API.
217+- The **⋯** menu opens a **New pull request** (the compare page against the
218+ default branch), the branch's **Activity**, the **View rules** that hold
219+ for it (Admin), and **Rename branch**. See
220+ [rename a branch](/guides/managing-repositories/#rename-a-branch) for what
221+ renaming does to pull requests, addresses and clones.
222+
223+The default branch is never deleted, and renaming it needs Admin.
224+[Rules](/guides/rules/) hold for every one of these changes: a ruleset that
225+restricts creating, deleting or renaming a branch refuses it here as it
226+would a push, and says why. When nothing protects the default branch,
227+people with the Admin role see a note saying so, with **Protect this
228+branch**, which starts a [ruleset](/guides/rules/), and **Dismiss**, which
229+this browser remembers for them and that repository. An archived
230+repository, or a mirror that g1t has not taken over, offers none of these
231+changes.
232+
233+From the API, a person's token with `repo:write` can do the same:
234+
235+| Route | MCP | What it does |
236+| --- | --- | --- |
237+| [`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`. |
238+| [`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. |
239+| [`POST /repos/{owner}/{name}/branches/{branch}/rename`](/reference/api/repositories/rename-branch/) | `repository` `rename_branch` | Renames it to `new_name`. |
240+
241+To restore a deleted branch, make it again with the `hash` deleting it
242+answered with as `from`. Encode a branch name with slashes as one path
243+segment: `feature/login` is `feature%2Flogin`.
244+
245+```sh
246+curl -X DELETE https://api.g1t.sh/repos/acme/rocket/branches/feature%2Flogin \
247+ -H "Authorization: Bearer $G1T_TOKEN"
248+# {"name": "feature/login", "hash": "9f2c4e1b…"}
249+
250+curl -X POST https://api.g1t.sh/repos/acme/rocket/branches \
251+ -H "Authorization: Bearer $G1T_TOKEN" \
252+ -H "Content-Type: application/json" \
253+ -d '{"name": "feature/login", "from": "9f2c4e1b…"}'
254+```
255+
256+Making a branch sends `git.push`, and deleting one sends `git.deleted`, to
257+[webhooks](/guides/webhooks/#events), as a push would; both
258+are recorded in the [audit log](/guides/audit-log/) as `branch.created` and
259+`branch.deleted`. g1t's token never deletes a branch.
189260
190261 The **Tags** tab lists tags newest first, up to 100, each with its commit
191262 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` |
+61−2
5454 | Part | What it shows |
5555 | --- | --- |
5656 | **What it is** | A badge, such as **App, deployed elsewhere** or **Library**, beside its homepage and docs. People who can change its settings open the badge to change [what it is](#what-a-project-is) in one click. |
57−| **Production** | For an app [deployed on g1t](/guides/deployments/): a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. |
57+| **Production** | [Production's picture](#productions-picture), which opens the live site, beside the rest. For an app [deployed on g1t](/guides/deployments/): its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with its latest reported production deployment or the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. |
5858 | **Packages** | For a library or a tool, in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. |
5959 | **Documentation**, **Homepage** | For docs, where they are read; for anything else, its homepage. |
6060 | **Get to production**, **Ship a release** or **Get started** | The [checklist](#the-checklist) for what the project is, until every step is done or you dismiss it. |
6565 | **About** | Its description, its [links](#links), what it is, and its latest release (its newest tag). People who can change its settings edit the description and links from here. |
6666 | **Health**, **Clone** | How often checks pass (on pull requests, or the project's workflow runs when it ships without them), recent builds and open issues by age; the clone address. |
6767
68+### Production's picture
69+
70+The production card always has a picture of the app, wherever it runs.
71+It is the first of these there is:
72+
73+1. **A screenshot of production.** For an app deployed on g1t, g1t takes
74+ one each time production goes live (see
75+ [the production screenshot](/guides/deployments/#the-production-screenshot));
76+ people with a role on the repository see it, like the rest of its
77+ deployments. For an app deployed elsewhere, g1t takes one of
78+ production's address: the address its latest production deployment
79+ reported to g1t gives, or else the one set for it under **What it is**.
80+ Anyone who can see the project sees it.
81+2. **An image someone uploaded**, under **Settings** → **General** →
82+ **Production image**.
83+3. **A plain frame** with production's address. People who can change
84+ the project's settings see **Add an image of your app** on it, which
85+ opens **Production image**.
86+
87+A screenshot of production deployed elsewhere is taken:
88+
89+- when its address is set or changed;
90+- when a production deployment of it is reported to g1t and succeeds,
91+ through the [API](/guides/deployments/) or by a workflow job with an
92+ `environment:`;
93+- otherwise about once a day, when the one kept is a day old. Opening the
94+ overview after that also takes a new one, in the background, while the
95+ old one shows.
96+
97+g1t waits up to 12 seconds for the page to settle, as a 1280 by 800
98+browser window sees it, then takes it as it is. Only public `http` and
99+`https` addresses on their usual ports are visited: an address on a
100+private network, a loopback or link-local address, or one of g1t's own
101+service addresses (other than `g1t.sh` itself) gets the uploaded image
102+or the frame instead. Screenshots are not charged.
103+
104+#### Uploading an image
105+
106+**Production image** takes a PNG, JPEG or WebP of at most 5 MB; wide
107+images fit best, such as 1280 by 800. It is checked by its contents, not
108+its name, and served from g1t's user-content address
109+(`g1tusercontent.com` on g1t.sh), never from the site itself. Its address
110+is made from its contents, so the card shows a new image as soon as it
111+is uploaded. Anyone with an image's address can open it, even for a
112+private project, so upload only what you would show.
113+
114+- The uploaded image shows whenever there is no screenshot: before the
115+ first one, when one cannot be taken, or for an app with no production
116+ address.
117+- **Use the uploaded image instead of a screenshot** shows it in place of
118+ any screenshot. While it is on, g1t takes no screenshots of production
119+ deployed elsewhere.
120+- **Remove** takes it away; the card goes back to the screenshot or the
121+ frame.
122+
123+Anyone who can change the project's settings (the **Maintain** role on
124+its repository and up, as for production's address) can upload, replace
125+or remove it.
126+
68127 ### The checklist
69128
70129 People with a role on its repository see a card that counts what the
216275
217276 | Tab | What it holds |
218277 | --- | --- |
219−| **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), and its [links](#links). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
278+| **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), its [links](#links), and its [production image](#uploading-an-image). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
220279 | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). |
221280 | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). |
222281 | **Agents** | How g1t's agents pick up work here, and what they read first. |
+2−0
289289 | [`update_release`](/reference/api/releases/update-release/) | Change its `release_name`, `body`, `draft` or `prerelease`. Write role. | `repo`, `id` | `repo:write` |
290290 | [`delete_release`](/reference/api/releases/delete-release/) | Delete a release; its tag stays. Write role. | `repo`, `id` | `repo:write` |
291291 | [`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` |
292+| [`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` |
293+| [`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` |
292294 | [`rename`](/reference/api/repositories/rename-repo/) | Give it a new name in its workspace; the old address redirects. Admin role. | `repo`, `name` | `repo:admin` |
293295 | [`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` |
294296 | [`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
+103−0
1+import { ImageUp, LoaderCircle, Trash2 } from "lucide-react";
2+import { useRef } from "react";
3+import { useFetcher } from "react-router";
4+
5+import type { ProductionImage } from "@g1t/contracts";
6+
7+import { PRODUCTION_IMAGE_ACCEPT } from "../lib/production-image";
8+import { ErrorText, SubmitButton, usePending } from "./ui";
9+import { Button } from "./ui/button";
10+import { Card } from "./ui/card";
11+import { SwitchCard } from "./ui/switch";
12+
13+/**
14+ * A project's production image, in its settings: the one shown now, a
15+ * button to pick a new one (sent as soon as it is picked), a way to remove
16+ * it, and whether it is shown instead of a screenshot. Posts
17+ * `intent=production-image` with the file in `image`,
18+ * `intent=remove-production-image`, or `intent=prefer-production-image`
19+ * with `preferred`.
20+ */
21+export function ProductionImageField({
22+ image,
23+ src,
24+ screenshots,
25+}: {
26+ image: ProductionImage | null;
27+ /** Where the image is served, when there is one. */
28+ src: string | null;
29+ /** Whether production gets a screenshot by itself: on g1t.page, or at an address elsewhere. */
30+ screenshots: boolean;
31+}) {
32+ const upload = useFetcher<{ error?: string }>();
33+ const prefer = useFetcher<{ error?: string }>();
34+ const form = useRef<HTMLFormElement>(null);
35+ const uploading = usePending({ intent: "production-image" }, upload);
36+ const removing = usePending({ intent: "remove-production-image" }, upload);
37+ const busy = uploading || removing;
38+ // Shown as asked while it is being saved.
39+ const preferred = prefer.formData ? prefer.formData.get("preferred") === "on" : (image?.preferred ?? false);
40+ return (
41+ <Card className="p-4">
42+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
43+ <div className="aspect-16/10 w-full shrink-0 overflow-hidden rounded-lg border border-line bg-bg sm:w-48">
44+ {src ? (
45+ <img src={src} alt="The uploaded image of production" width={1280} height={800} className="size-full object-cover object-top" />
46+ ) : (
47+ <div className="flex size-full items-center justify-center text-faint">
48+ <ImageUp size={20} aria-hidden="true" />
49+ </div>
50+ )}
51+ </div>
52+ <div className="min-w-0 grow space-y-2">
53+ <p className="text-sm text-muted">
54+ {screenshots
55+ ? "Shown on the overview while there is no screenshot of production, or instead of one."
56+ : "Shown on the overview's production card. Give production's address, and a screenshot of it is taken by itself."}
57+ </p>
58+ <div className="flex flex-wrap items-center gap-2">
59+ <upload.Form method="post" encType="multipart/form-data" ref={form}>
60+ <input type="hidden" name="intent" value="production-image" />
61+ <Button asChild variant="outline" className={busy ? "pointer-events-none opacity-50" : "cursor-pointer"}>
62+ <label>
63+ {uploading ? <LoaderCircle size={15} aria-hidden="true" className="animate-spin" /> : <ImageUp size={15} />}
64+ {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
65+ <input
66+ type="file"
67+ name="image"
68+ accept={PRODUCTION_IMAGE_ACCEPT}
69+ className="sr-only"
70+ onChange={(event) => {
71+ if (event.currentTarget.files?.length) form.current?.requestSubmit();
72+ }}
73+ />
74+ </label>
75+ </Button>
76+ </upload.Form>
77+ {image && (
78+ <upload.Form method="post">
79+ <input type="hidden" name="intent" value="remove-production-image" />
80+ <SubmitButton fetcher={upload} variant="outline" pending="Removing…" match={{ intent: "remove-production-image" }} disabled={uploading}>
81+ <Trash2 size={15} />
82+ Remove
83+ </SubmitButton>
84+ </upload.Form>
85+ )}
86+ </div>
87+ <p className="text-xs text-faint">PNG, JPEG or WebP, at most 5 MB. Wide images fit best, such as 1280 × 800.</p>
88+ <ErrorText>{upload.data?.error ?? prefer.data?.error}</ErrorText>
89+ </div>
90+ </div>
91+ {image && screenshots && (
92+ <SwitchCard
93+ className="mt-4"
94+ title="Use the uploaded image instead of a screenshot"
95+ checked={preferred}
96+ onCheckedChange={(on) => prefer.submit({ intent: "prefer-production-image", preferred: on ? "on" : "" }, { method: "post" })}
97+ >
98+ No screenshot of production is taken while it is on.
99+ </SwitchCard>
100+ )}
101+ </Card>
102+ );
103+}
+77−30
1−import { Globe } from "lucide-react";
1+import { Globe, ImagePlus } from "lucide-react";
22 import { useEffect, useRef, useState } from "react";
33
44 import { cn } from "../lib/cn";
5+import type { CardSource } from "../lib/production-image";
56 import { DeployLink } from "./deploy";
7+import { ButtonLink } from "./ui";
68
79 /**
8− * A thumbnail of production as it is now, linking to it. The screenshot is
9− * taken once per deploy; until it has loaded, or when there is none yet, a
10− * quiet frame with the address stands in.
10+ * A thumbnail of production, linking to it: a screenshot (taken once per
11+ * deploy on g1t.page, or of the address of production deployed
12+ * elsewhere), else an image someone uploaded (lib/production-image.ts
13+ * `cardSources`). Each source is tried in turn, the next when one does not
14+ * load. Until one has loaded, or when there is none, a quiet frame with
15+ * the address stands in; to someone who may change the project's
16+ * settings it offers to add an image.
1117 */
1218 export function ProductionShot({
13− src,
19+ sources,
1420 href,
1521 label,
22+ note,
23+ addImage,
1624 className,
1725 }: {
18− /** Null when nothing is deployed yet. */
19− src: string | null;
26+ /** The images to try, in order; empty for none. */
27+ sources: CardSource[];
2028 href: string | null;
2129 label: string;
30+ /** What the empty frame says, such as when a screenshot comes; none for a quiet frame. */
31+ note?: string | null;
32+ /** Where an image is added, for someone who may; null for anyone else. */
33+ addImage?: string | null;
2234 className?: string;
2335 }) {
2436 const image = useRef<HTMLImageElement>(null);
25− const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
37+ const [at, setAt] = useState(0);
38+ const [loaded, setLoaded] = useState(false);
39+ const key = sources.map((source) => source.src).join("\n");
40+ const source = sources[at] ?? null;
41+
42+ // Other sources (another deploy, a new upload): from the first again.
43+ useEffect(() => {
44+ setAt(0);
45+ setLoaded(false);
46+ }, [key]);
2647
2748 // An image that settled before hydration fired its events unheard.
2849 useEffect(() => {
2950 const img = image.current;
3051 if (!img?.complete) return;
31− setState(img.naturalWidth > 0 ? "loaded" : "failed");
32− }, [src]);
52+ if (img.naturalWidth > 0) setLoaded(true);
53+ else setAt((index) => index + 1);
54+ }, [source?.src]);
55+
56+ const empty = source == null;
57+ const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null;
3358
3459 const frame = (
3560 <span
3661 className={cn(
37− "relative block aspect-[16/10] w-full overflow-hidden rounded-lg border border-line bg-bg",
62+ "relative block aspect-16/10 w-full overflow-hidden rounded-lg border border-line bg-bg",
3863 href && "transition-colors group-hover:border-line-strong",
3964 )}
4065 >
41− {state !== "loaded" && <Placeholder label={label} waiting={src != null && state === "loading"} />}
42− {src && state !== "failed" && (
66+ {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
67+ {source && (
4368 <img
69+ key={source.src}
4470 ref={image}
45− src={src}
46− alt={`Screenshot of ${label}`}
71+ src={source.src}
72+ alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`}
4773 width={1280}
4874 height={800}
4975 decoding="async"
50− onLoad={() => setState("loaded")}
51− onError={() => setState("failed")}
76+ onLoad={() => setLoaded(true)}
77+ onError={() => {
78+ setLoaded(false);
79+ setAt((index) => index + 1);
80+ }}
5281 className={cn(
5382 "absolute inset-0 size-full object-cover object-top transition-opacity duration-300",
54− state === "loaded" ? "opacity-100" : "opacity-0",
83+ loaded ? "opacity-100" : "opacity-0",
5584 )}
5685 />
5786 )}
5887 </span>
5988 );
6089
61− return href ? (
62− <DeployLink href={href} className={cn("group block", className)} aria-label={`Visit ${label}`}>
63− {frame}
64− </DeployLink>
65− ) : (
66− <div className={className}>{frame}</div>
90+ return (
91+ <div className={cn("relative", className)}>
92+ {href ? (
93+ <DeployLink href={href} className="group block" aria-label={`Visit ${label}`}>
94+ {frame}
95+ </DeployLink>
96+ ) : (
97+ frame
98+ )}
99+ {empty && addImage && (
100+ // Beside the link, not in it: a link holds no other link.
101+ <span className="pointer-events-none absolute inset-x-0 top-7 bottom-0 flex flex-col items-center justify-center gap-2 px-3 text-center">
102+ <ButtonLink to={addImage} variant="outline" size="xs" className="pointer-events-auto bg-surface">
103+ <ImagePlus size={13} />
104+ Add an image of your app
105+ </ButtonLink>
106+ {note && <span className="text-[0.6875rem] text-faint">{note}</span>}
107+ </span>
108+ )}
109+ </div>
67110 );
68111 }
69112
70−function Placeholder({ label, waiting }: { label: string; waiting: boolean }) {
113+/** The frame without a picture: a browser's bar with the address, and faint lines of a page. */
114+function Placeholder({ label, waiting, note }: { label: string; waiting: string | null; note: string | null }) {
115+ const line = waiting ?? note;
71116 return (
72117 <span className="absolute inset-0 flex flex-col">
73118 <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5">
74119 <span className="size-1.5 rounded-full bg-line-strong" />
75120 <span className="size-1.5 rounded-full bg-line-strong" />
76121 <span className="size-1.5 rounded-full bg-line-strong" />
77− <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>
122+ {label && <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>}
78123 </span>
79124 <span className="relative flex grow flex-col gap-2 p-3.5">
80125 <span className="h-2 w-2/5 rounded-full bg-line/80" />
81126 <span className="h-1.5 w-4/5 rounded-full bg-line/50" />
82127 <span className="h-1.5 w-3/5 rounded-full bg-line/50" />
83− <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
84− <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
85− {waiting ? "Taking a screenshot…" : "A screenshot appears after the next deploy"}
86− </span>
128+ {line && (
129+ <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint">
130+ <Globe size={12} className={waiting ? "animate-pulse" : undefined} />
131+ {line}
132+ </span>
133+ )}
87134 </span>
88135 </span>
89136 );
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…