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 {
454454 color: var(--fb-accent);
455455 border-color: var(--fb-accent);
456456 }
457+.badge-archived {
458+ color: var(--fb-warning);
459+ border-color: var(--fb-warning);
460+}
457461
458462 /* Inline search row: input grows, button sits to its right with margin. */
459463 .search-row {
crates/cli/src/repo_cmd.rs +38 −0
@@ -91,6 +91,20 @@ pub(crate) enum RepoCommand {
9191 #[command(subcommand)]
9292 command: CollaboratorCommand,
9393 },
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+ },
94108 /// Print the resolved on-disk directory of a repository.
95109 Path {
96110 /// The owning user.
@@ -202,10 +216,34 @@ pub(crate) fn run(
202216 block_on(collab_list(config_path, user, name, json))
203217 }
204218 },
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+ }
205225 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
206226 }
207227 }
208228
229+async 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+
209247 /// Look up a user's id by name, or error cleanly.
210248 async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
211249 store
crates/ssh/src/server.rs +5 −0
@@ -290,6 +290,11 @@ impl GitHandler {
290290 return Err(not_found());
291291 };
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+
293298 let collaborator = self
294299 .shared
295300 .store
crates/store/src/repos.rs +20 −0
@@ -215,6 +215,26 @@ impl Store {
215215 Ok(updated > 0)
216216 }
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+
218238 /// Record that a repository was just pushed to: stamp `pushed_at` and refresh
219239 /// `size_bytes`. Called by `fabrica hook post-receive`.
220240 ///
crates/web/src/lib.rs +1 −0
@@ -148,6 +148,7 @@ pub fn build_router(state: AppState) -> Router {
148148 // Repo administration (owner/admin only; enforced in the handlers). A
149149 // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all.
150150 .route("/repo-settings/{id}/general", post(repo::settings_general))
151+ .route("/repo-settings/{id}/archive", post(repo::settings_archive))
151152 .route("/repo-settings/{id}/delete", post(repo::settings_delete))
152153 .route(
153154 "/repo-settings/{id}/collaborators",
crates/web/src/repo.rs +98 −19
@@ -736,9 +736,6 @@ async fn repo_settings_view(
736736 option value=(value.as_str()) selected[vis == value] { (label) }
737737 }
738738 };
739- let perm_option = |value: &str, label: &str| {
740- html! { option value=(value) { (label) } }
741- };
742739 let body = html! {
743740 (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches))
744741 section class="listing" {
@@ -758,6 +755,47 @@ async fn repo_settings_view(
758755 }
759756 }
760757 }
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.
796+fn 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! {
761799 section class="listing" {
762800 h2 { "Collaborators" }
763801 div class="card" {
@@ -765,7 +803,7 @@ async fn repo_settings_view(
765803 p class="muted" { "No collaborators yet." }
766804 } @else {
767805 ul class="entry-list collab-list" {
768- @for c in &collaborators {
806+ @for c in collaborators {
769807 li class="entry-row" {
770808 div class="entry-row-body" {
771809 a class="entry-row-title" href={ "/" (c.username) } { (c.username) }
@@ -774,7 +812,7 @@ async fn repo_settings_view(
774812 div class="entry-row-actions" {
775813 form method="post"
776814 action=(format!("/repo-settings/{id}/collaborators/remove")) {
777- input type="hidden" name="_csrf" value=(chrome.csrf);
815+ input type="hidden" name="_csrf" value=(csrf);
778816 input type="hidden" name="user_id" value=(c.user_id);
779817 button class="btn btn-danger" type="submit" { "Remove" }
780818 }
@@ -785,7 +823,7 @@ async fn repo_settings_view(
785823 }
786824 form class="collab-add" method="post"
787825 action=(format!("/repo-settings/{id}/collaborators")) {
788- input type="hidden" name="_csrf" value=(chrome.csrf);
826+ input type="hidden" name="_csrf" value=(csrf);
789827 input type="text" name="username" placeholder="Username" aria-label="Username" required;
790828 select name="permission" aria-label="Permission" {
791829 (perm_option("read", "Read"))
@@ -796,19 +834,7 @@ async fn repo_settings_view(
796834 }
797835 }
798836 }
799- section class="listing" {
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())
837+ }
812838 }
813839
814840 /// Resolve a repo by id and require the viewer to have `Admin` access, else a
@@ -958,6 +984,44 @@ pub async fn settings_delete(
958984 }
959985 }
960986
987+/// The archive/unarchive form.
988+#[derive(Debug, Deserialize)]
989+pub 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.
999+pub 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+
9611025 /// The `/-/settings` URL for a repo, resolving the owner username.
9621026 async fn repo_settings_url(state: &AppState, repo: &Repo) -> String {
9631027 let owner = state
@@ -1503,6 +1567,8 @@ pub(crate) struct RepoEntry {
15031567 /// When the repo was last pushed to (falling back to its last metadata
15041568 /// change), for the "Updated …" subtitle.
15051569 pub(crate) updated: i64,
1570+ /// Whether the repo is archived (shows an archived badge).
1571+ pub(crate) archived: bool,
15061572 /// Optional one-line description, shown under the name when present.
15071573 pub(crate) description: Option<String>,
15081574 }
@@ -1516,6 +1582,7 @@ impl RepoEntry {
15161582 visibility: repo.visibility,
15171583 default_branch: repo.default_branch.clone(),
15181584 updated: repo.pushed_at.unwrap_or(repo.updated_at),
1585+ archived: repo.archived_at.is_some(),
15191586 description: repo.description.clone(),
15201587 }
15211588 }
@@ -1529,6 +1596,7 @@ impl RepoEntry {
15291596 visibility: repo.visibility,
15301597 default_branch: repo.default_branch.clone(),
15311598 updated: repo.pushed_at.unwrap_or(repo.updated_at),
1599+ archived: repo.archived_at.is_some(),
15321600 description: repo.description.clone(),
15331601 }
15341602 }
@@ -1543,6 +1611,15 @@ fn visibility_badge(visibility: model::Visibility) -> Markup {
15431611 }
15441612 }
15451613
1614+/// An "archived" badge when the repo is archived, else nothing.
1615+fn archived_badge(archived: bool) -> Markup {
1616+ html! {
1617+ @if archived {
1618+ span class="badge badge-archived" { "archived" }
1619+ }
1620+ }
1621+}
1622+
15461623 /// Render a titled card of repository rows in the reference listing style: a
15471624 /// card headed by `title`, one bordered row per repo with a bold name and a
15481625 /// muted `visibility · default: branch` subtitle.
@@ -1561,6 +1638,7 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
15611638 span class="repo-row-name" {
15621639 (icon(Icon::Box)) span { (e.label) }
15631640 (visibility_badge(e.visibility))
1641+ (archived_badge(e.archived))
15641642 }
15651643 @if let Some(desc) = &e.description {
15661644 span class="repo-row-desc muted" { (desc) }
@@ -1698,6 +1776,7 @@ fn repo_header(
16981776 span class="slug-sep" { "/" }
16991777 a href=(base) { (ctx.repo.name) }
17001778 (visibility_badge(ctx.repo.visibility))
1779+ (archived_badge(ctx.repo.archived_at.is_some()))
17011780 }
17021781 @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } }
17031782 div class="repo-nav" {