fabrica

hanna/fabrica

feat: archive repositories (read-only) with an archived badge

a6f131a · hanna committed on 2026-07-25

Add repo archiving: store set_archived, a `repo archive`/`repo unarchive`
CLI, an Archive section on the repo Settings page, and an archived badge in
the repo header and listings. SSH refuses pushes (receive-pack) to an
archived repo. Extract the collaborators settings block into a helper to keep
the settings view within the line limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +166 −19UnifiedSplit
assets/base.css +4 −0
@@ -454,6 +454,10 @@ select:focus {
454 color: var(--fb-accent);454 color: var(--fb-accent);
455 border-color: var(--fb-accent);455 border-color: var(--fb-accent);
456}456}
457.badge-archived {
458 color: var(--fb-warning);
459 border-color: var(--fb-warning);
460}
457461
458/* Inline search row: input grows, button sits to its right with margin. */462/* Inline search row: input grows, button sits to its right with margin. */
459.search-row {463.search-row {
crates/cli/src/repo_cmd.rs +38 −0
@@ -91,6 +91,20 @@ pub(crate) enum RepoCommand {
91 #[command(subcommand)]91 #[command(subcommand)]
92 command: CollaboratorCommand,92 command: CollaboratorCommand,
93 },93 },
94 /// Archive a repository (makes it read-only).
95 Archive {
96 /// The owning user.
97 user: String,
98 /// The repo name (group path included).
99 name: String,
100 },
101 /// Unarchive a repository.
102 Unarchive {
103 /// The owning user.
104 user: String,
105 /// The repo name (group path included).
106 name: String,
107 },
94 /// Print the resolved on-disk directory of a repository.108 /// Print the resolved on-disk directory of a repository.
95 Path {109 Path {
96 /// The owning user.110 /// The owning user.
@@ -202,10 +216,34 @@ pub(crate) fn run(
202 block_on(collab_list(config_path, user, name, json))216 block_on(collab_list(config_path, user, name, json))
203 }217 }
204 },218 },
219 RepoCommand::Archive { user, name } => {
220 block_on(set_archived(config_path, user, name, true))
221 }
222 RepoCommand::Unarchive { user, name } => {
223 block_on(set_archived(config_path, user, name, false))
224 }
205 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),225 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
206 }226 }
207}227}
208228
229async fn set_archived(
230 config_path: Option<&Path>,
231 user: &str,
232 name: &str,
233 archived: bool,
234) -> anyhow::Result<ExitCode> {
235 let (_config, store) = open_store(config_path).await?;
236 let owner_id = require_user_id(&store, user).await?;
237 let repo = require_repo(&store, &owner_id, name, user).await?;
238 store.set_archived(&repo.id, archived).await?;
239 println!(
240 "{user}/{} is now {}",
241 repo.path,
242 if archived { "archived" } else { "active" }
243 );
244 Ok(ExitCode::SUCCESS)
245}
246
209/// Look up a user's id by name, or error cleanly.247/// Look up a user's id by name, or error cleanly.
210async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {248async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
211 store249 store
crates/ssh/src/server.rs +5 −0
@@ -290,6 +290,11 @@ impl GitHandler {
290 return Err(not_found());290 return Err(not_found());
291 };291 };
292292
293 // Archived repositories are read-only: refuse any push.
294 if matches!(service, git::pack::Service::ReceivePack) && repo.archived_at.is_some() {
295 return Err("This repository is archived and is read-only.".to_string());
296 }
297
293 let collaborator = self298 let collaborator = self
294 .shared299 .shared
295 .store300 .store
crates/store/src/repos.rs +20 −0
@@ -215,6 +215,26 @@ impl Store {
215 Ok(updated > 0)215 Ok(updated > 0)
216 }216 }
217217
218 /// Archive or unarchive a repository. Archiving stamps `archived_at`;
219 /// unarchiving clears it. Returns `true` if the row existed and was updated.
220 ///
221 /// # Errors
222 ///
223 /// Returns [`StoreError::Query`] on a database failure.
224 pub async fn set_archived(&self, repo_id: &str, archived: bool) -> Result<bool, StoreError> {
225 let now = now_ms();
226 let archived_at = archived.then_some(now);
227 let updated =
228 sqlx::query("UPDATE repos SET archived_at = $1, updated_at = $2 WHERE id = $3")
229 .bind(archived_at)
230 .bind(now)
231 .bind(repo_id)
232 .execute(&self.pool)
233 .await?
234 .rows_affected();
235 Ok(updated > 0)
236 }
237
218 /// Record that a repository was just pushed to: stamp `pushed_at` and refresh238 /// Record that a repository was just pushed to: stamp `pushed_at` and refresh
219 /// `size_bytes`. Called by `fabrica hook post-receive`.239 /// `size_bytes`. Called by `fabrica hook post-receive`.
220 ///240 ///
crates/web/src/lib.rs +1 −0
@@ -148,6 +148,7 @@ pub fn build_router(state: AppState) -> Router {
148 // Repo administration (owner/admin only; enforced in the handlers). A148 // Repo administration (owner/admin only; enforced in the handlers). A
149 // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all.149 // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all.
150 .route("/repo-settings/{id}/general", post(repo::settings_general))150 .route("/repo-settings/{id}/general", post(repo::settings_general))
151 .route("/repo-settings/{id}/archive", post(repo::settings_archive))
151 .route("/repo-settings/{id}/delete", post(repo::settings_delete))152 .route("/repo-settings/{id}/delete", post(repo::settings_delete))
152 .route(153 .route(
153 "/repo-settings/{id}/collaborators",154 "/repo-settings/{id}/collaborators",
crates/web/src/repo.rs +98 −19
@@ -736,9 +736,6 @@ async fn repo_settings_view(
736 option value=(value.as_str()) selected[vis == value] { (label) }736 option value=(value.as_str()) selected[vis == value] { (label) }
737 }737 }
738 };738 };
739 let perm_option = |value: &str, label: &str| {
740 html! { option value=(value) { (label) } }
741 };
742 let body = html! {739 let body = html! {
743 (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches))740 (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches))
744 section class="listing" {741 section class="listing" {
@@ -758,6 +755,47 @@ async fn repo_settings_view(
758 }755 }
759 }756 }
760 }757 }
758 (collaborators_section(id, &chrome.csrf, &collaborators))
759 section class="listing" {
760 h2 { "Archive" }
761 div class="card" {
762 @if ctx.repo.archived_at.is_some() {
763 p class="muted" { "This repository is archived (read-only). Unarchiving restores pushing." }
764 form method="post" action=(format!("/repo-settings/{id}/archive")) {
765 input type="hidden" name="_csrf" value=(chrome.csrf);
766 input type="hidden" name="archived" value="false";
767 button class="btn btn-primary" type="submit" { "Unarchive repository" }
768 }
769 } @else {
770 p class="muted" { "Archiving makes the repository read-only; pushes are refused until it is unarchived." }
771 form method="post" action=(format!("/repo-settings/{id}/archive")) {
772 input type="hidden" name="_csrf" value=(chrome.csrf);
773 input type="hidden" name="archived" value="true";
774 button class="btn" type="submit" { "Archive repository" }
775 }
776 }
777 }
778 }
779 section class="listing" {
780 h2 { "Danger zone" }
781 div class="card danger-zone" {
782 p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." }
783 form method="post" action=(format!("/repo-settings/{id}/delete")) {
784 input type="hidden" name="_csrf" value=(chrome.csrf);
785 button class="btn btn-danger" type="submit" { "Delete repository" }
786 }
787 }
788 }
789 };
790 let title = format!("{}/{}: settings", ctx.owner.username, ctx.repo.path);
791 Ok((jar, page(&chrome, &title, body)).into_response())
792}
793
794/// The Collaborators section of the repo settings page: the current list with
795/// remove buttons, and the add form.
796fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {
797 let perm_option = |value: &str, label: &str| html! { option value=(value) { (label) } };
798 html! {
761 section class="listing" {799 section class="listing" {
762 h2 { "Collaborators" }800 h2 { "Collaborators" }
763 div class="card" {801 div class="card" {
@@ -765,7 +803,7 @@ async fn repo_settings_view(
765 p class="muted" { "No collaborators yet." }803 p class="muted" { "No collaborators yet." }
766 } @else {804 } @else {
767 ul class="entry-list collab-list" {805 ul class="entry-list collab-list" {
768 @for c in &collaborators {806 @for c in collaborators {
769 li class="entry-row" {807 li class="entry-row" {
770 div class="entry-row-body" {808 div class="entry-row-body" {
771 a class="entry-row-title" href={ "/" (c.username) } { (c.username) }809 a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
@@ -774,7 +812,7 @@ async fn repo_settings_view(
774 div class="entry-row-actions" {812 div class="entry-row-actions" {
775 form method="post"813 form method="post"
776 action=(format!("/repo-settings/{id}/collaborators/remove")) {814 action=(format!("/repo-settings/{id}/collaborators/remove")) {
777 input type="hidden" name="_csrf" value=(chrome.csrf);815 input type="hidden" name="_csrf" value=(csrf);
778 input type="hidden" name="user_id" value=(c.user_id);816 input type="hidden" name="user_id" value=(c.user_id);
779 button class="btn btn-danger" type="submit" { "Remove" }817 button class="btn btn-danger" type="submit" { "Remove" }
780 }818 }
@@ -785,7 +823,7 @@ async fn repo_settings_view(
785 }823 }
786 form class="collab-add" method="post"824 form class="collab-add" method="post"
787 action=(format!("/repo-settings/{id}/collaborators")) {825 action=(format!("/repo-settings/{id}/collaborators")) {
788 input type="hidden" name="_csrf" value=(chrome.csrf);826 input type="hidden" name="_csrf" value=(csrf);
789 input type="text" name="username" placeholder="Username" aria-label="Username" required;827 input type="text" name="username" placeholder="Username" aria-label="Username" required;
790 select name="permission" aria-label="Permission" {828 select name="permission" aria-label="Permission" {
791 (perm_option("read", "Read"))829 (perm_option("read", "Read"))
@@ -796,19 +834,7 @@ async fn repo_settings_view(
796 }834 }
797 }835 }
798 }836 }
799 section class="listing" {837 }
800 h2 { "Danger zone" }
801 div class="card danger-zone" {
802 p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." }
803 form method="post" action=(format!("/repo-settings/{id}/delete")) {
804 input type="hidden" name="_csrf" value=(chrome.csrf);
805 button class="btn btn-danger" type="submit" { "Delete repository" }
806 }
807 }
808 }
809 };
810 let title = format!("{}/{}: settings", ctx.owner.username, ctx.repo.path);
811 Ok((jar, page(&chrome, &title, body)).into_response())
812}838}
813839
814/// Resolve a repo by id and require the viewer to have `Admin` access, else a840/// Resolve a repo by id and require the viewer to have `Admin` access, else a
@@ -958,6 +984,44 @@ pub async fn settings_delete(
958 }984 }
959}985}
960986
987/// The archive/unarchive form.
988#[derive(Debug, Deserialize)]
989pub struct RepoArchiveForm {
990 /// Desired archived state (`true` to archive, `false` to unarchive).
991 #[serde(default)]
992 archived: String,
993 /// CSRF token.
994 #[serde(rename = "_csrf")]
995 csrf: String,
996}
997
998/// `POST /repo-settings/{id}/archive` — archive or unarchive the repo.
999pub async fn settings_archive(
1000 State(state): State<AppState>,
1001 MaybeUser(viewer): MaybeUser,
1002 jar: CookieJar,
1003 headers: HeaderMap,
1004 Path(id): Path<String>,
1005 Form(form): Form<RepoArchiveForm>,
1006) -> Response {
1007 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1008 return AppError::Forbidden.into_response();
1009 }
1010 let result = async {
1011 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1012 state
1013 .store
1014 .set_archived(&repo.id, form.archived == "true")
1015 .await?;
1016 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1017 }
1018 .await;
1019 match result {
1020 Ok(dest) => Redirect::to(&dest).into_response(),
1021 Err(err) => err.into_response(),
1022 }
1023}
1024
961/// The `/-/settings` URL for a repo, resolving the owner username.1025/// The `/-/settings` URL for a repo, resolving the owner username.
962async fn repo_settings_url(state: &AppState, repo: &Repo) -> String {1026async fn repo_settings_url(state: &AppState, repo: &Repo) -> String {
963 let owner = state1027 let owner = state
@@ -1503,6 +1567,8 @@ pub(crate) struct RepoEntry {
1503 /// When the repo was last pushed to (falling back to its last metadata1567 /// When the repo was last pushed to (falling back to its last metadata
1504 /// change), for the "Updated …" subtitle.1568 /// change), for the "Updated …" subtitle.
1505 pub(crate) updated: i64,1569 pub(crate) updated: i64,
1570 /// Whether the repo is archived (shows an archived badge).
1571 pub(crate) archived: bool,
1506 /// Optional one-line description, shown under the name when present.1572 /// Optional one-line description, shown under the name when present.
1507 pub(crate) description: Option<String>,1573 pub(crate) description: Option<String>,
1508}1574}
@@ -1516,6 +1582,7 @@ impl RepoEntry {
1516 visibility: repo.visibility,1582 visibility: repo.visibility,
1517 default_branch: repo.default_branch.clone(),1583 default_branch: repo.default_branch.clone(),
1518 updated: repo.pushed_at.unwrap_or(repo.updated_at),1584 updated: repo.pushed_at.unwrap_or(repo.updated_at),
1585 archived: repo.archived_at.is_some(),
1519 description: repo.description.clone(),1586 description: repo.description.clone(),
1520 }1587 }
1521 }1588 }
@@ -1529,6 +1596,7 @@ impl RepoEntry {
1529 visibility: repo.visibility,1596 visibility: repo.visibility,
1530 default_branch: repo.default_branch.clone(),1597 default_branch: repo.default_branch.clone(),
1531 updated: repo.pushed_at.unwrap_or(repo.updated_at),1598 updated: repo.pushed_at.unwrap_or(repo.updated_at),
1599 archived: repo.archived_at.is_some(),
1532 description: repo.description.clone(),1600 description: repo.description.clone(),
1533 }1601 }
1534 }1602 }
@@ -1543,6 +1611,15 @@ fn visibility_badge(visibility: model::Visibility) -> Markup {
1543 }1611 }
1544}1612}
15451613
1614/// An "archived" badge when the repo is archived, else nothing.
1615fn archived_badge(archived: bool) -> Markup {
1616 html! {
1617 @if archived {
1618 span class="badge badge-archived" { "archived" }
1619 }
1620 }
1621}
1622
1546/// Render a titled card of repository rows in the reference listing style: a1623/// Render a titled card of repository rows in the reference listing style: a
1547/// card headed by `title`, one bordered row per repo with a bold name and a1624/// card headed by `title`, one bordered row per repo with a bold name and a
1548/// muted `visibility · default: branch` subtitle.1625/// muted `visibility · default: branch` subtitle.
@@ -1561,6 +1638,7 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
1561 span class="repo-row-name" {1638 span class="repo-row-name" {
1562 (icon(Icon::Box)) span { (e.label) }1639 (icon(Icon::Box)) span { (e.label) }
1563 (visibility_badge(e.visibility))1640 (visibility_badge(e.visibility))
1641 (archived_badge(e.archived))
1564 }1642 }
1565 @if let Some(desc) = &e.description {1643 @if let Some(desc) = &e.description {
1566 span class="repo-row-desc muted" { (desc) }1644 span class="repo-row-desc muted" { (desc) }
@@ -1698,6 +1776,7 @@ fn repo_header(
1698 span class="slug-sep" { "/" }1776 span class="slug-sep" { "/" }
1699 a href=(base) { (ctx.repo.name) }1777 a href=(base) { (ctx.repo.name) }
1700 (visibility_badge(ctx.repo.visibility))1778 (visibility_badge(ctx.repo.visibility))
1779 (archived_badge(ctx.repo.archived_at.is_some()))
1701 }1780 }
1702 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }1781 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
1703 div class="repo-nav" {1782 div class="repo-nav" {