Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +8 −0
| @@ -2002,6 +2002,14 @@ pre.code { | |||
| 2002 | 2002 | .repo-head { | |
| 2003 | 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 | 2013 | .repo-slug { | |
| 2006 | 2014 | display: flex; | |
| 2007 | 2015 | align-items: center; | |
crates/auth/src/access.rs +1 −0
| @@ -151,6 +151,7 @@ mod tests { | |||
| 151 | 151 | issues_enabled: true, | |
| 152 | 152 | pulls_enabled: true, | |
| 153 | 153 | is_mirror: false, | |
| 154 | + | fork_parent_id: None, | |
| 154 | 155 | next_iid: 1, | |
| 155 | 156 | archived_at: None, | |
| 156 | 157 | size_bytes: 0, | |
crates/model/src/entity.rs +2 −0
| @@ -427,6 +427,8 @@ pub struct Repo { | |||
| 427 | 427 | pub pulls_enabled: bool, | |
| 428 | 428 | /// Whether this repository is a mirror (kept in sync from a remote). | |
| 429 | 429 | pub is_mirror: bool, | |
| 430 | + | /// The repository this one was forked from, if any. | |
| 431 | + | pub fork_parent_id: Option<String>, | |
| 430 | 432 | /// The next issue/PR number to allocate (shared counter). | |
| 431 | 433 | pub next_iid: Timestamp, | |
| 432 | 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 | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | |
| 14 | 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, \ | |
| 16 | - | archived_at, size_bytes, pushed_at, created_at, updated_at"; | |
| 15 | + | default_branch, object_format, issues_enabled, pulls_enabled, is_mirror, fork_parent_id, \ | |
| 16 | + | next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at"; | |
| 17 | 17 | ||
| 18 | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, | |
| 19 | 19 | /// and the derived size/push bookkeeping. | |
| @@ -70,6 +70,7 @@ impl Store { | |||
| 70 | 70 | issues_enabled: true, | |
| 71 | 71 | pulls_enabled: true, | |
| 72 | 72 | is_mirror: false, | |
| 73 | + | fork_parent_id: None, | |
| 73 | 74 | next_iid: 1, | |
| 74 | 75 | archived_at: None, | |
| 75 | 76 | size_bytes: 0, | |
| @@ -306,6 +307,34 @@ impl Store { | |||
| 306 | 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 | 338 | /// Archive or unarchive a repository. Archiving stamps `archived_at`; | |
| 310 | 339 | /// unarchiving clears it. Returns `true` if the row existed and was updated. | |
| 311 | 340 | /// | |
| @@ -467,6 +496,7 @@ impl Store { | |||
| 467 | 496 | issues_enabled: self.get_bool(row, "issues_enabled")?, | |
| 468 | 497 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, | |
| 469 | 498 | is_mirror: self.get_bool(row, "is_mirror")?, | |
| 499 | + | fork_parent_id: row.try_get("fork_parent_id")?, | |
| 470 | 500 | next_iid: row.try_get("next_iid")?, | |
| 471 | 501 | archived_at: row.try_get("archived_at")?, | |
| 472 | 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 | 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 | 860 | async fn mirrors_create_list_due_and_delete() { | |
| 829 | 861 | for fx in sqlite_fixtures().await { | |
| 830 | 862 | let owner = fx | |
crates/web/src/lib.rs +4 −0
| @@ -315,6 +315,10 @@ pub fn build_router(state: AppState) -> Router { | |||
| 315 | 315 | "/new/migrate", | |
| 316 | 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 | 322 | // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all. | |
| 319 | 323 | .route("/issue-new/{repo_id}", post(issues::create)) | |
| 320 | 324 | .route("/issue/{id}/comment", post(issues::comment)) | |
crates/web/src/repo.rs +214 −0
| @@ -76,6 +76,10 @@ pub(crate) struct RepoCtx { | |||
| 76 | 76 | /// The viewer's access level, used to gate the Settings tab and its actions. | |
| 77 | 77 | /// Every browse view requires only `Read`. | |
| 78 | 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 | 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 | 148 | if access == auth::Access::None { | |
| 145 | 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 | 156 | Ok(Some(RepoCtx { | |
| 148 | 157 | owner, | |
| 149 | 158 | repo, | |
| 150 | 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 | 177 | /// A listing filter query (`?q=`), shared by the profile and explore views. | |
| 155 | 178 | #[derive(Debug, Default, Deserialize)] | |
| 156 | 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 | 1766 | /// Resolve a repo by id and require the viewer to have `Admin` access, else a | |
| 1564 | 1767 | /// `404` (so a non-admin cannot even confirm the repo exists). | |
| 1565 | 1768 | async fn require_admin_repo( | |
| @@ -2615,6 +2818,12 @@ pub(crate) fn repo_header( | |||
| 2615 | 2818 | (visibility_badge(ctx.repo.visibility)) | |
| 2616 | 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 | 2827 | @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } } | |
| 2619 | 2828 | div class="repo-nav" { | |
| 2620 | 2829 | nav class="tabs repo-tabs" { | |
| @@ -2633,6 +2842,11 @@ pub(crate) fn repo_header( | |||
| 2633 | 2842 | } | |
| 2634 | 2843 | } | |
| 2635 | 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 | 2850 | (branch_menu(&base, rev, branches)) | |
| 2637 | 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; | |