Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +8 −0
| @@ -2002,6 +2002,14 @@ pre.code { | |||
| 2002 | .repo-head { | 2002 | .repo-head { |
| 2003 | margin-bottom: 1.25rem; | 2003 | margin-bottom: 1.25rem; |
| 2004 | } | 2004 | } |
| 2005 | /* "forked from owner/repo" line under the repo slug. */ | ||
| 2006 | .fork-note { | ||
| 2007 | display: flex; | ||
| 2008 | align-items: center; | ||
| 2009 | gap: 0.3rem; | ||
| 2010 | font-size: 0.9em; | ||
| 2011 | margin: 0.2rem 0 0; | ||
| 2012 | } | ||
| 2005 | .repo-slug { | 2013 | .repo-slug { |
| 2006 | display: flex; | 2014 | display: flex; |
| 2007 | align-items: center; | 2015 | align-items: center; |
crates/auth/src/access.rs +1 −0
| @@ -151,6 +151,7 @@ mod tests { | |||
| 151 | issues_enabled: true, | 151 | issues_enabled: true, |
| 152 | pulls_enabled: true, | 152 | pulls_enabled: true, |
| 153 | is_mirror: false, | 153 | is_mirror: false, |
| 154 | fork_parent_id: None, | ||
| 154 | next_iid: 1, | 155 | next_iid: 1, |
| 155 | archived_at: None, | 156 | archived_at: None, |
| 156 | size_bytes: 0, | 157 | size_bytes: 0, |
crates/model/src/entity.rs +2 −0
| @@ -427,6 +427,8 @@ pub struct Repo { | |||
| 427 | pub pulls_enabled: bool, | 427 | pub pulls_enabled: bool, |
| 428 | /// Whether this repository is a mirror (kept in sync from a remote). | 428 | /// Whether this repository is a mirror (kept in sync from a remote). |
| 429 | pub is_mirror: bool, | 429 | pub is_mirror: bool, |
| 430 | /// The repository this one was forked from, if any. | ||
| 431 | pub fork_parent_id: Option<String>, | ||
| 430 | /// The next issue/PR number to allocate (shared counter). | 432 | /// The next issue/PR number to allocate (shared counter). |
| 431 | pub next_iid: Timestamp, | 433 | pub next_iid: Timestamp, |
| 432 | /// When the repo was archived (read-only), if it is. | 434 | /// When the repo was archived (read-only), if it is. |
crates/store/src/repos.rs +32 −2
| @@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |||
| 12 | 12 | ||
| 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. |
| 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ | 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ |
| 15 | default_branch, object_format, issues_enabled, pulls_enabled, is_mirror, next_iid, \ | 15 | default_branch, object_format, issues_enabled, pulls_enabled, is_mirror, fork_parent_id, \ |
| 16 | archived_at, size_bytes, pushed_at, created_at, updated_at"; | 16 | next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at"; |
| 17 | 17 | ||
| 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, |
| 19 | /// and the derived size/push bookkeeping. | 19 | /// and the derived size/push bookkeeping. |
| @@ -70,6 +70,7 @@ impl Store { | |||
| 70 | issues_enabled: true, | 70 | issues_enabled: true, |
| 71 | pulls_enabled: true, | 71 | pulls_enabled: true, |
| 72 | is_mirror: false, | 72 | is_mirror: false, |
| 73 | fork_parent_id: None, | ||
| 73 | next_iid: 1, | 74 | next_iid: 1, |
| 74 | archived_at: None, | 75 | archived_at: None, |
| 75 | size_bytes: 0, | 76 | size_bytes: 0, |
| @@ -306,6 +307,34 @@ impl Store { | |||
| 306 | Ok(updated > 0) | 307 | Ok(updated > 0) |
| 307 | } | 308 | } |
| 308 | 309 | ||
| 310 | /// Record that `repo_id` was forked from `parent_id`. | ||
| 311 | /// | ||
| 312 | /// # Errors | ||
| 313 | /// | ||
| 314 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 315 | pub async fn set_fork_parent(&self, repo_id: &str, parent_id: &str) -> Result<(), StoreError> { | ||
| 316 | sqlx::query("UPDATE repos SET fork_parent_id = $1 WHERE id = $2") | ||
| 317 | .bind(parent_id) | ||
| 318 | .bind(repo_id) | ||
| 319 | .execute(&self.pool) | ||
| 320 | .await?; | ||
| 321 | Ok(()) | ||
| 322 | } | ||
| 323 | |||
| 324 | /// The number of repositories forked from `repo_id`. | ||
| 325 | /// | ||
| 326 | /// # Errors | ||
| 327 | /// | ||
| 328 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 329 | pub async fn count_forks(&self, repo_id: &str) -> Result<i64, StoreError> { | ||
| 330 | let n: i64 = sqlx::query("SELECT COUNT(*) AS n FROM repos WHERE fork_parent_id = $1") | ||
| 331 | .bind(repo_id) | ||
| 332 | .fetch_one(&self.pool) | ||
| 333 | .await? | ||
| 334 | .try_get("n")?; | ||
| 335 | Ok(n) | ||
| 336 | } | ||
| 337 | |||
| 309 | /// Archive or unarchive a repository. Archiving stamps `archived_at`; | 338 | /// Archive or unarchive a repository. Archiving stamps `archived_at`; |
| 310 | /// unarchiving clears it. Returns `true` if the row existed and was updated. | 339 | /// unarchiving clears it. Returns `true` if the row existed and was updated. |
| 311 | /// | 340 | /// |
| @@ -467,6 +496,7 @@ impl Store { | |||
| 467 | issues_enabled: self.get_bool(row, "issues_enabled")?, | 496 | issues_enabled: self.get_bool(row, "issues_enabled")?, |
| 468 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, | 497 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, |
| 469 | is_mirror: self.get_bool(row, "is_mirror")?, | 498 | is_mirror: self.get_bool(row, "is_mirror")?, |
| 499 | fork_parent_id: row.try_get("fork_parent_id")?, | ||
| 470 | next_iid: row.try_get("next_iid")?, | 500 | next_iid: row.try_get("next_iid")?, |
| 471 | archived_at: row.try_get("archived_at")?, | 501 | archived_at: row.try_get("archived_at")?, |
| 472 | size_bytes: row.try_get("size_bytes")?, | 502 | size_bytes: row.try_get("size_bytes")?, |
crates/store/src/tests.rs +32 −0
| @@ -825,6 +825,38 @@ async fn ensure_group_path_rejects_excessive_nesting() { | |||
| 825 | } | 825 | } |
| 826 | 826 | ||
| 827 | #[tokio::test] | 827 | #[tokio::test] |
| 828 | async fn fork_parent_and_count_round_trip() { | ||
| 829 | for fx in sqlite_fixtures().await { | ||
| 830 | let owner = fx | ||
| 831 | .store | ||
| 832 | .create_user(sample_user("fk", "fk@example.com")) | ||
| 833 | .await | ||
| 834 | .unwrap(); | ||
| 835 | let mk = |name: &str| NewRepo { | ||
| 836 | owner_id: owner.id.clone(), | ||
| 837 | group_id: None, | ||
| 838 | name: name.to_string(), | ||
| 839 | path: name.to_string(), | ||
| 840 | description: None, | ||
| 841 | visibility: model::Visibility::Public, | ||
| 842 | default_branch: "main".to_string(), | ||
| 843 | }; | ||
| 844 | let parent = fx.store.create_repo(mk("upstream")).await.unwrap(); | ||
| 845 | let fork = fx.store.create_repo(mk("myfork")).await.unwrap(); | ||
| 846 | assert!(fork.fork_parent_id.is_none()); | ||
| 847 | assert_eq!(fx.store.count_forks(&parent.id).await.unwrap(), 0); | ||
| 848 | |||
| 849 | fx.store | ||
| 850 | .set_fork_parent(&fork.id, &parent.id) | ||
| 851 | .await | ||
| 852 | .unwrap(); | ||
| 853 | let reloaded = fx.store.repo_by_id(&fork.id).await.unwrap().unwrap(); | ||
| 854 | assert_eq!(reloaded.fork_parent_id.as_deref(), Some(parent.id.as_str())); | ||
| 855 | assert_eq!(fx.store.count_forks(&parent.id).await.unwrap(), 1); | ||
| 856 | } | ||
| 857 | } | ||
| 858 | |||
| 859 | #[tokio::test] | ||
| 828 | async fn mirrors_create_list_due_and_delete() { | 860 | async fn mirrors_create_list_due_and_delete() { |
| 829 | for fx in sqlite_fixtures().await { | 861 | for fx in sqlite_fixtures().await { |
| 830 | let owner = fx | 862 | let owner = fx |
crates/web/src/lib.rs +4 −0
| @@ -315,6 +315,10 @@ pub fn build_router(state: AppState) -> Router { | |||
| 315 | "/new/migrate", | 315 | "/new/migrate", |
| 316 | get(pages::migrate_form).post(pages::migrate_submit), | 316 | get(pages::migrate_form).post(pages::migrate_submit), |
| 317 | ) | 317 | ) |
| 318 | .route( | ||
| 319 | "/repo-fork/{id}", | ||
| 320 | get(repo::fork_confirm).post(repo::fork_create), | ||
| 321 | ) | ||
| 318 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. | 322 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. |
| 319 | .route("/issue-new/{repo_id}", post(issues::create)) | 323 | .route("/issue-new/{repo_id}", post(issues::create)) |
| 320 | .route("/issue/{id}/comment", post(issues::comment)) | 324 | .route("/issue/{id}/comment", post(issues::comment)) |
crates/web/src/repo.rs +214 −0
| @@ -76,6 +76,10 @@ pub(crate) struct RepoCtx { | |||
| 76 | /// The viewer's access level, used to gate the Settings tab and its actions. | 76 | /// The viewer's access level, used to gate the Settings tab and its actions. |
| 77 | /// Every browse view requires only `Read`. | 77 | /// Every browse view requires only `Read`. |
| 78 | pub(crate) access: auth::Access, | 78 | pub(crate) access: auth::Access, |
| 79 | /// The signed-in viewer's id, if any (gates the Fork action). | ||
| 80 | pub(crate) viewer_id: Option<String>, | ||
| 81 | /// The parent repo's `(owner_username, path)` when this repo is a fork. | ||
| 82 | pub(crate) fork_parent: Option<(String, String)>, | ||
| 79 | } | 83 | } |
| 80 | 84 | ||
| 81 | /// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo | 85 | /// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo |
| @@ -144,13 +148,32 @@ pub(crate) async fn resolve( | |||
| 144 | if access == auth::Access::None { | 148 | if access == auth::Access::None { |
| 145 | return Err(AppError::NotFound); | 149 | return Err(AppError::NotFound); |
| 146 | } | 150 | } |
| 151 | // Resolve the fork parent's owner/path for the "forked from" line (best-effort). | ||
| 152 | let fork_parent = match &repo.fork_parent_id { | ||
| 153 | Some(pid) => resolve_fork_parent(state, pid).await, | ||
| 154 | None => None, | ||
| 155 | }; | ||
| 147 | Ok(Some(RepoCtx { | 156 | Ok(Some(RepoCtx { |
| 148 | owner, | 157 | owner, |
| 149 | repo, | 158 | repo, |
| 150 | access, | 159 | access, |
| 160 | viewer_id: viewer.map(|u| u.id.clone()), | ||
| 161 | fork_parent, | ||
| 151 | })) | 162 | })) |
| 152 | } | 163 | } |
| 153 | 164 | ||
| 165 | /// Resolve a parent repo id to its `(owner_username, path)` for display. | ||
| 166 | async fn resolve_fork_parent(state: &AppState, parent_id: &str) -> Option<(String, String)> { | ||
| 167 | let parent = state.store.repo_by_id(parent_id).await.ok().flatten()?; | ||
| 168 | let owner = state | ||
| 169 | .store | ||
| 170 | .user_by_id(&parent.owner_id) | ||
| 171 | .await | ||
| 172 | .ok() | ||
| 173 | .flatten()?; | ||
| 174 | Some((owner.username, parent.path)) | ||
| 175 | } | ||
| 176 | |||
| 154 | /// A listing filter query (`?q=`), shared by the profile and explore views. | 177 | /// A listing filter query (`?q=`), shared by the profile and explore views. |
| 155 | #[derive(Debug, Default, Deserialize)] | 178 | #[derive(Debug, Default, Deserialize)] |
| 156 | pub struct ListQuery { | 179 | pub struct ListQuery { |
| @@ -1560,6 +1583,186 @@ pub async fn settings_mirror_sync( | |||
| 1560 | } | 1583 | } |
| 1561 | } | 1584 | } |
| 1562 | 1585 | ||
| 1586 | /// Load a repo by id and require the viewer to be able to read it, else `404`. | ||
| 1587 | async fn require_readable_repo( | ||
| 1588 | state: &AppState, | ||
| 1589 | viewer: Option<&User>, | ||
| 1590 | repo_id: &str, | ||
| 1591 | ) -> Result<Repo, AppError> { | ||
| 1592 | let repo = state | ||
| 1593 | .store | ||
| 1594 | .repo_by_id(repo_id) | ||
| 1595 | .await? | ||
| 1596 | .ok_or(AppError::NotFound)?; | ||
| 1597 | let viewer_id = viewer.map(|u| auth::Viewer { | ||
| 1598 | id: u.id.clone(), | ||
| 1599 | is_admin: u.is_admin, | ||
| 1600 | }); | ||
| 1601 | let collaborator = match viewer { | ||
| 1602 | Some(u) => state | ||
| 1603 | .store | ||
| 1604 | .collaborator_permission(&repo.id, &u.id) | ||
| 1605 | .await? | ||
| 1606 | .and_then(|p| auth::Permission::parse(&p)), | ||
| 1607 | None => None, | ||
| 1608 | }; | ||
| 1609 | let access = auth::access( | ||
| 1610 | viewer_id.as_ref(), | ||
| 1611 | &repo, | ||
| 1612 | collaborator, | ||
| 1613 | state.allow_anonymous(), | ||
| 1614 | ); | ||
| 1615 | if access == auth::Access::None { | ||
| 1616 | return Err(AppError::NotFound); | ||
| 1617 | } | ||
| 1618 | Ok(repo) | ||
| 1619 | } | ||
| 1620 | |||
| 1621 | /// `GET /repo-fork/{id}` — confirm forking a readable repo to your account. | ||
| 1622 | pub async fn fork_confirm( | ||
| 1623 | State(state): State<AppState>, | ||
| 1624 | MaybeUser(viewer): MaybeUser, | ||
| 1625 | jar: CookieJar, | ||
| 1626 | uri: Uri, | ||
| 1627 | Path(id): Path<String>, | ||
| 1628 | ) -> AppResult<Response> { | ||
| 1629 | let Some(user) = viewer.clone() else { | ||
| 1630 | return Ok(Redirect::to("/login").into_response()); | ||
| 1631 | }; | ||
| 1632 | let source = require_readable_repo(&state, viewer.as_ref(), &id).await?; | ||
| 1633 | let source_owner = state | ||
| 1634 | .store | ||
| 1635 | .user_by_id(&source.owner_id) | ||
| 1636 | .await? | ||
| 1637 | .ok_or(AppError::NotFound)?; | ||
| 1638 | let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); | ||
| 1639 | let body = html! { | ||
| 1640 | div class="new-repo" { | ||
| 1641 | h1 { "Fork a repository" } | ||
| 1642 | div class="card" { | ||
| 1643 | p { "Create a copy of " | ||
| 1644 | strong { (source_owner.username) "/" (source.path) } | ||
| 1645 | " under your account, " strong { (user.username) } "." } | ||
| 1646 | form method="post" action=(format!("/repo-fork/{}", source.id)) { | ||
| 1647 | input type="hidden" name="_csrf" value=(chrome.csrf); | ||
| 1648 | label for="fk-name" { "Repository name" } | ||
| 1649 | input type="text" id="fk-name" name="name" value=(source.name) required; | ||
| 1650 | button class="btn btn-primary inline-btn" type="submit" { "Fork repository" } | ||
| 1651 | } | ||
| 1652 | } | ||
| 1653 | } | ||
| 1654 | }; | ||
| 1655 | Ok((jar, page(&chrome, "Fork a repository", body)).into_response()) | ||
| 1656 | } | ||
| 1657 | |||
| 1658 | /// The fork form. | ||
| 1659 | #[derive(Debug, serde::Deserialize)] | ||
| 1660 | pub struct ForkForm { | ||
| 1661 | #[serde(default)] | ||
| 1662 | name: String, | ||
| 1663 | #[serde(rename = "_csrf")] | ||
| 1664 | csrf: String, | ||
| 1665 | } | ||
| 1666 | |||
| 1667 | /// `POST /repo-fork/{id}` — create the fork (row + on-disk copy) and redirect. | ||
| 1668 | pub async fn fork_create( | ||
| 1669 | State(state): State<AppState>, | ||
| 1670 | MaybeUser(viewer): MaybeUser, | ||
| 1671 | jar: CookieJar, | ||
| 1672 | headers: HeaderMap, | ||
| 1673 | Path(id): Path<String>, | ||
| 1674 | Form(form): Form<ForkForm>, | ||
| 1675 | ) -> Response { | ||
| 1676 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 1677 | return AppError::Forbidden.into_response(); | ||
| 1678 | } | ||
| 1679 | let Some(user) = viewer.clone() else { | ||
| 1680 | return Redirect::to("/login").into_response(); | ||
| 1681 | }; | ||
| 1682 | let source = match require_readable_repo(&state, viewer.as_ref(), &id).await { | ||
| 1683 | Ok(repo) => repo, | ||
| 1684 | Err(e) => return e.into_response(), | ||
| 1685 | }; | ||
| 1686 | match do_fork(&state, &user, &source, form.name.trim()).await { | ||
| 1687 | Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(), | ||
| 1688 | Err(msg) => AppError::BadRequest(msg).into_response(), | ||
| 1689 | } | ||
| 1690 | } | ||
| 1691 | |||
| 1692 | /// Create the fork repo (row + bare repo on disk seeded from the source). | ||
| 1693 | async fn do_fork( | ||
| 1694 | state: &AppState, | ||
| 1695 | user: &User, | ||
| 1696 | source: &Repo, | ||
| 1697 | name: &str, | ||
| 1698 | ) -> Result<String, String> { | ||
| 1699 | let leaf = name.trim(); | ||
| 1700 | if leaf.is_empty() { | ||
| 1701 | return Err("Repository name is required.".to_string()); | ||
| 1702 | } | ||
| 1703 | let repo = state | ||
| 1704 | .store | ||
| 1705 | .create_repo(store::NewRepo { | ||
| 1706 | owner_id: user.id.clone(), | ||
| 1707 | group_id: None, | ||
| 1708 | name: leaf.to_string(), | ||
| 1709 | path: leaf.to_string(), | ||
| 1710 | description: source.description.clone(), | ||
| 1711 | visibility: source.visibility, | ||
| 1712 | default_branch: source.default_branch.clone(), | ||
| 1713 | }) | ||
| 1714 | .await | ||
| 1715 | .map_err(|e| match e { | ||
| 1716 | store::StoreError::Conflict { .. } => { | ||
| 1717 | "You already have a repository with that name.".to_string() | ||
| 1718 | } | ||
| 1719 | store::StoreError::Name(_) => "Invalid repository name.".to_string(), | ||
| 1720 | other => other.to_string(), | ||
| 1721 | })?; | ||
| 1722 | let _ = state.store.set_fork_parent(&repo.id, &source.id).await; | ||
| 1723 | |||
| 1724 | let format = git::ObjectFormat::from_token(&source.object_format); | ||
| 1725 | if format != git::ObjectFormat::Sha1 { | ||
| 1726 | let _ = state | ||
| 1727 | .store | ||
| 1728 | .set_object_format(&repo.id, format.as_str()) | ||
| 1729 | .await; | ||
| 1730 | } | ||
| 1731 | let repo_dir = &state.config.storage.repo_dir; | ||
| 1732 | let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); | ||
| 1733 | if let Err(e) = | ||
| 1734 | git::create_bare_with_format(repo_dir, &repo.id, &source.default_branch, &hook, format) | ||
| 1735 | { | ||
| 1736 | let _ = state.store.delete_repo(&repo.id).await; | ||
| 1737 | return Err(format!("Could not create the fork on disk: {e}")); | ||
| 1738 | } | ||
| 1739 | // Seed the fork by fetching the source's refs over a local path. | ||
| 1740 | let (Ok(src_dir), Ok(dest_dir)) = ( | ||
| 1741 | git::repo_path(repo_dir, &source.id), | ||
| 1742 | git::repo_path(repo_dir, &repo.id), | ||
| 1743 | ) else { | ||
| 1744 | let _ = state.store.delete_repo(&repo.id).await; | ||
| 1745 | return Err("Could not locate the repositories on disk.".to_string()); | ||
| 1746 | }; | ||
| 1747 | let home = state.config.storage.data_dir.join("tmp"); | ||
| 1748 | let src_url = src_dir.to_string_lossy().into_owned(); | ||
| 1749 | if let Err(e) = git::mirror::fetch( | ||
| 1750 | &state.config.git.binary, | ||
| 1751 | &dest_dir, | ||
| 1752 | &src_url, | ||
| 1753 | git::mirror::RemoteCred::default(), | ||
| 1754 | &home, | ||
| 1755 | ) | ||
| 1756 | .await | ||
| 1757 | { | ||
| 1758 | let _ = state.store.delete_repo(&repo.id).await; | ||
| 1759 | return Err(format!("Could not copy the repository contents: {e}")); | ||
| 1760 | } | ||
| 1761 | let size = i64::try_from(crate::admin::dir_size(&dest_dir)).unwrap_or(0); | ||
| 1762 | let _ = state.store.record_push(&repo.id, size).await; | ||
| 1763 | Ok(repo.path) | ||
| 1764 | } | ||
| 1765 | |||
| 1563 | /// Resolve a repo by id and require the viewer to have `Admin` access, else a | 1766 | /// Resolve a repo by id and require the viewer to have `Admin` access, else a |
| 1564 | /// `404` (so a non-admin cannot even confirm the repo exists). | 1767 | /// `404` (so a non-admin cannot even confirm the repo exists). |
| 1565 | async fn require_admin_repo( | 1768 | async fn require_admin_repo( |
| @@ -2615,6 +2818,12 @@ pub(crate) fn repo_header( | |||
| 2615 | (visibility_badge(ctx.repo.visibility)) | 2818 | (visibility_badge(ctx.repo.visibility)) |
| 2616 | (archived_badge(ctx.repo.archived_at.is_some())) | 2819 | (archived_badge(ctx.repo.archived_at.is_some())) |
| 2617 | } | 2820 | } |
| 2821 | @if let Some((powner, ppath)) = &ctx.fork_parent { | ||
| 2822 | p class="muted fork-note" { | ||
| 2823 | (icon(Icon::Mirror)) " forked from " | ||
| 2824 | a href=(format!("/{powner}/{ppath}")) { (powner) "/" (ppath) } | ||
| 2825 | } | ||
| 2826 | } | ||
| 2618 | @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } } | 2827 | @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } } |
| 2619 | div class="repo-nav" { | 2828 | div class="repo-nav" { |
| 2620 | nav class="tabs repo-tabs" { | 2829 | nav class="tabs repo-tabs" { |
| @@ -2633,6 +2842,11 @@ pub(crate) fn repo_header( | |||
| 2633 | } | 2842 | } |
| 2634 | } | 2843 | } |
| 2635 | div class="repo-actions" { | 2844 | div class="repo-actions" { |
| 2845 | @if ctx.viewer_id.is_some() { | ||
| 2846 | a class="btn" href=(format!("/repo-fork/{}", ctx.repo.id)) { | ||
| 2847 | (icon(Icon::GitBranch)) span { "Fork" } | ||
| 2848 | } | ||
| 2849 | } | ||
| 2636 | (branch_menu(&base, rev, branches)) | 2850 | (branch_menu(&base, rev, branches)) |
| 2637 | (clone_menu(state, ctx)) | 2851 | (clone_menu(state, ctx)) |
| 2638 | } | 2852 | } |
migrations/postgres/0016_repo_fork.sql +3 −0
| @@ -0,0 +1,3 @@ | |||
| 1 | -- Fork relationship: a repo created as a fork points at its parent. The parent | ||
| 2 | -- surviving a fork means renames are metadata-only; deleting a parent detaches. | ||
| 3 | ALTER TABLE repos ADD COLUMN fork_parent_id TEXT REFERENCES repos(id) ON DELETE SET NULL; | ||
migrations/sqlite/0016_repo_fork.sql +3 −0
| @@ -0,0 +1,3 @@ | |||
| 1 | -- Fork relationship: a repo created as a fork points at its parent. The parent | ||
| 2 | -- surviving a fork means renames are metadata-only; deleting a parent detaches. | ||
| 3 | ALTER TABLE repos ADD COLUMN fork_parent_id TEXT REFERENCES repos(id) ON DELETE SET NULL; | ||