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 {
20022002 .repo-head {
20032003 margin-bottom: 1.25rem;
20042004 }
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+}
20052013 .repo-slug {
20062014 display: flex;
20072015 align-items: center;
crates/auth/src/access.rs +1 −0
@@ -151,6 +151,7 @@ mod tests {
151151 issues_enabled: true,
152152 pulls_enabled: true,
153153 is_mirror: false,
154+ fork_parent_id: None,
154155 next_iid: 1,
155156 archived_at: None,
156157 size_bytes: 0,
crates/model/src/entity.rs +2 −0
@@ -427,6 +427,8 @@ pub struct Repo {
427427 pub pulls_enabled: bool,
428428 /// Whether this repository is a mirror (kept in sync from a remote).
429429 pub is_mirror: bool,
430+ /// The repository this one was forked from, if any.
431+ pub fork_parent_id: Option<String>,
430432 /// The next issue/PR number to allocate (shared counter).
431433 pub next_iid: Timestamp,
432434 /// 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
1313 /// The columns of `repos` in a fixed order, shared by every `SELECT`.
1414 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";
1717
1818 /// Fields needed to create a repository. The server fills in the id, timestamps,
1919 /// and the derived size/push bookkeeping.
@@ -70,6 +70,7 @@ impl Store {
7070 issues_enabled: true,
7171 pulls_enabled: true,
7272 is_mirror: false,
73+ fork_parent_id: None,
7374 next_iid: 1,
7475 archived_at: None,
7576 size_bytes: 0,
@@ -306,6 +307,34 @@ impl Store {
306307 Ok(updated > 0)
307308 }
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+
309338 /// Archive or unarchive a repository. Archiving stamps `archived_at`;
310339 /// unarchiving clears it. Returns `true` if the row existed and was updated.
311340 ///
@@ -467,6 +496,7 @@ impl Store {
467496 issues_enabled: self.get_bool(row, "issues_enabled")?,
468497 pulls_enabled: self.get_bool(row, "pulls_enabled")?,
469498 is_mirror: self.get_bool(row, "is_mirror")?,
499+ fork_parent_id: row.try_get("fork_parent_id")?,
470500 next_iid: row.try_get("next_iid")?,
471501 archived_at: row.try_get("archived_at")?,
472502 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() {
825825 }
826826
827827 #[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]
828860 async fn mirrors_create_list_due_and_delete() {
829861 for fx in sqlite_fixtures().await {
830862 let owner = fx
crates/web/src/lib.rs +4 −0
@@ -315,6 +315,10 @@ pub fn build_router(state: AppState) -> Router {
315315 "/new/migrate",
316316 get(pages::migrate_form).post(pages::migrate_submit),
317317 )
318+ .route(
319+ "/repo-fork/{id}",
320+ get(repo::fork_confirm).post(repo::fork_create),
321+ )
318322 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
319323 .route("/issue-new/{repo_id}", post(issues::create))
320324 .route("/issue/{id}/comment", post(issues::comment))
crates/web/src/repo.rs +214 −0
@@ -76,6 +76,10 @@ pub(crate) struct RepoCtx {
7676 /// The viewer's access level, used to gate the Settings tab and its actions.
7777 /// Every browse view requires only `Read`.
7878 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)>,
7983 }
8084
8185 /// Run a blocking libgit2 read for a repo on the blocking pool, opening the repo
@@ -144,13 +148,32 @@ pub(crate) async fn resolve(
144148 if access == auth::Access::None {
145149 return Err(AppError::NotFound);
146150 }
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+ };
147156 Ok(Some(RepoCtx {
148157 owner,
149158 repo,
150159 access,
160+ viewer_id: viewer.map(|u| u.id.clone()),
161+ fork_parent,
151162 }))
152163 }
153164
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+
154177 /// A listing filter query (`?q=`), shared by the profile and explore views.
155178 #[derive(Debug, Default, Deserialize)]
156179 pub struct ListQuery {
@@ -1560,6 +1583,186 @@ pub async fn settings_mirror_sync(
15601583 }
15611584 }
15621585
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+
15631766 /// Resolve a repo by id and require the viewer to have `Admin` access, else a
15641767 /// `404` (so a non-admin cannot even confirm the repo exists).
15651768 async fn require_admin_repo(
@@ -2615,6 +2818,12 @@ pub(crate) fn repo_header(
26152818 (visibility_badge(ctx.repo.visibility))
26162819 (archived_badge(ctx.repo.archived_at.is_some()))
26172820 }
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+ }
26182827 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
26192828 div class="repo-nav" {
26202829 nav class="tabs repo-tabs" {
@@ -2633,6 +2842,11 @@ pub(crate) fn repo_header(
26332842 }
26342843 }
26352844 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+ }
26362850 (branch_menu(&base, rev, branches))
26372851 (clone_menu(state, ctx))
26382852 }
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;