fabrica

hanna/fabrica

feat: repository forks

0add8bf · hanna committed on 2026-07-26

Add repos.fork_parent_id (migration 0016) and the fork flow:

- A "Fork" button in the repo header (signed-in viewers) links to a
  confirm page; POST creates a repo under the viewer, copies the source's
  refs on disk (create_bare + a local git fetch), and records the parent.
- The fork inherits the source's visibility, default branch, object
  format, and description; a name collision in the viewer's namespace is
  reported.
- Forks show a "forked from owner/repo" line under the slug. Store gains
  set_fork_parent and count_forks; RepoCtx carries the viewer id and the
  resolved parent for display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +299 −2UnifiedSplit
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};
1212
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`.
14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \14const 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";
1717
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 }
308309
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}
826826
827#[tokio::test]827#[tokio::test]
828async 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]
828async fn mirrors_create_list_due_and_delete() {860async fn mirrors_create_list_due_and_delete() {
829 for fx in sqlite_fixtures().await {861 for fx in sqlite_fixtures().await {
830 let owner = fx862 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}
8084
81/// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo85/// 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}
153164
165/// Resolve a parent repo id to its `(owner_username, path)` for display.
166async 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)]
156pub struct ListQuery {179pub struct ListQuery {
@@ -1560,6 +1583,186 @@ pub async fn settings_mirror_sync(
1560 }1583 }
1561}1584}
15621585
1586/// Load a repo by id and require the viewer to be able to read it, else `404`.
1587async 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.
1622pub 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)]
1660pub 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.
1668pub 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).
1693async 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 a1766/// 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).
1565async fn require_admin_repo(1768async 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.
3ALTER 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.
3ALTER TABLE repos ADD COLUMN fork_parent_id TEXT REFERENCES repos(id) ON DELETE SET NULL;