// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! `fabrica repo` — repository administration, operating directly on the store and //! the on-disk object storage. use std::path::Path; use std::process::ExitCode; use clap::Subcommand; use model::Repo; use store::{NewRepo, Store}; use crate::context::{block_on, hook_binary, open_store}; use crate::prompt::confirm; /// Actions under `fabrica repo`. #[derive(Debug, Subcommand)] pub(crate) enum RepoCommand { /// Create a repository. `name` may include a group path (`backend/api/svc`), /// creating any missing intermediate groups. Add { /// The owning user. user: String, /// The repo name, optionally prefixed with a group path. name: String, /// Make the repo private (owner and collaborators only). #[arg(long, conflicts_with_all = ["public", "internal"])] private: bool, /// Make the repo internal (any signed-in user). #[arg(long, conflicts_with = "public")] internal: bool, /// Make the repo public (everyone). #[arg(long)] public: bool, /// A one-line description. #[arg(long)] description: Option, /// Override the instance default branch. #[arg(long = "default-branch")] default_branch: Option, }, /// Delete a repository. Its directory is moved to trash unless `--purge`. Del { /// The owning user. user: String, /// The repo name (group path included). name: String, /// Delete the on-disk repository immediately instead of moving it to trash. #[arg(long)] purge: bool, /// Skip the confirmation prompt (required when stdin is not a terminal). #[arg(long)] yes: bool, }, /// List repositories, optionally filtered to one user. List { /// Restrict to a single owner. #[arg(long)] user: Option, }, /// Rename a repository (metadata only — the on-disk directory never moves). Rename { /// The owning user. user: String, /// The current repo name (group path included). name: String, /// The new leaf name (the group path is preserved). new_name: String, }, /// Change a repository's visibility (private / internal / public). Visibility { /// The owning user. user: String, /// The repo name (group path included). name: String, /// Make the repo private (owner and collaborators only). #[arg(long, conflicts_with_all = ["public", "internal"])] private: bool, /// Make the repo internal (any signed-in user). #[arg(long, conflicts_with = "public")] internal: bool, /// Make the repo public (everyone). #[arg(long)] public: bool, }, /// Manage a repository's collaborators. Collaborator { /// The collaborator action. #[command(subcommand)] command: CollaboratorCommand, }, /// Archive a repository (makes it read-only). Archive { /// The owning user. user: String, /// The repo name (group path included). name: String, }, /// Unarchive a repository. Unarchive { /// The owning user. user: String, /// The repo name (group path included). name: String, }, /// Print the resolved on-disk directory of a repository. Path { /// The owning user. user: String, /// The repo name (group path included). name: String, }, } /// Actions under `fabrica repo collaborator`. #[derive(Debug, Subcommand)] pub(crate) enum CollaboratorCommand { /// Add or update a collaborator's permission. Add { /// The owning user. user: String, /// The repo name (group path included). name: String, /// The collaborator's username. collaborator: String, /// Permission to grant: read | write | admin. #[arg(long, default_value = "write")] permission: String, }, /// Remove a collaborator. Rm { /// The owning user. user: String, /// The repo name (group path included). name: String, /// The collaborator's username. collaborator: String, }, /// List a repository's collaborators. List { /// The owning user. user: String, /// The repo name (group path included). name: String, }, } /// Dispatch a `repo` subcommand. pub(crate) fn run( command: &RepoCommand, config_path: Option<&Path>, json: bool, ) -> anyhow::Result { match command { RepoCommand::Add { user, name, private, internal, public, description, default_branch, } => block_on(add( config_path, user, name, (*private, *internal, *public), description.clone(), default_branch.clone(), )), RepoCommand::Del { user, name, purge, yes, } => block_on(del(config_path, user, name, *purge, *yes)), RepoCommand::List { user } => block_on(list(config_path, user.as_deref(), json)), RepoCommand::Rename { user, name, new_name, } => block_on(rename(config_path, user, name, new_name)), RepoCommand::Visibility { user, name, private, internal, public, } => block_on(visibility( config_path, user, name, (*private, *internal, *public), )), RepoCommand::Collaborator { command } => match command { CollaboratorCommand::Add { user, name, collaborator, permission, } => block_on(collab_add( config_path, user, name, collaborator, permission, )), CollaboratorCommand::Rm { user, name, collaborator, } => block_on(collab_rm(config_path, user, name, collaborator)), CollaboratorCommand::List { user, name } => { block_on(collab_list(config_path, user, name, json)) } }, RepoCommand::Archive { user, name } => { block_on(set_archived(config_path, user, name, true)) } RepoCommand::Unarchive { user, name } => { block_on(set_archived(config_path, user, name, false)) } RepoCommand::Path { user, name } => block_on(path(config_path, user, name)), } } async fn set_archived( config_path: Option<&Path>, user: &str, name: &str, archived: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; store.set_archived(&repo.id, archived).await?; println!( "{user}/{} is now {}", repo.path, if archived { "archived" } else { "active" } ); Ok(ExitCode::SUCCESS) } /// Look up a user's id by name, or error cleanly. async fn require_user_id(store: &Store, name: &str) -> anyhow::Result { store .user_by_username(name) .await? .map(|u| u.id) .ok_or_else(|| anyhow::anyhow!("no such user {name:?}")) } /// Look up a repository by owner and path, or error cleanly. async fn require_repo( store: &Store, owner_id: &str, path: &str, user: &str, ) -> anyhow::Result { store .repo_by_owner_path(owner_id, path) .await? .ok_or_else(|| anyhow::anyhow!("no such repository {user}/{path}")) } /// Split a repo name into its group-path prefix (if any) and leaf name. fn split_group_path(name: &str) -> (Option<&str>, &str) { match name.rsplit_once('/') { Some((group, leaf)) => (Some(group), leaf), None => (None, name), } } /// Resolve the visibility from the `(private, internal, public)` flags, falling /// back to the instance default when none is given. clap guarantees at most one /// flag is set. fn resolve_visibility( flags: (bool, bool, bool), default: config::DefaultVisibility, ) -> model::Visibility { let (private, internal, public) = flags; if private { model::Visibility::Private } else if internal { model::Visibility::Internal } else if public { model::Visibility::Public } else { model::Visibility::from_token(default.as_token()).unwrap_or(model::Visibility::Private) } } async fn add( config_path: Option<&Path>, user: &str, name: &str, flags: (bool, bool, bool), description: Option, default_branch: Option, ) -> anyhow::Result { let (config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let (group_path, _leaf) = split_group_path(name); let group_id = match group_path { Some(gp) => Some(store.ensure_group_path(&owner_id, gp).await?.id), None => None, }; let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone()); let visibility = resolve_visibility(flags, config.instance.default_visibility); let repo = store .create_repo(NewRepo { owner_id, group_id, name: split_group_path(name).1.to_string(), path: name.to_string(), description, visibility, default_branch: branch.clone(), }) .await?; // Create the bare repository on disk; roll the row back if that fails so a // half-created repo never lingers in the database. if let Err(err) = git::create_bare(&config.storage.repo_dir, &repo.id, &branch, &hook_binary()) { let _ = store.delete_repo(&repo.id).await; return Err(err.into()); } println!("created {visibility} repository {user}/{}", repo.path); Ok(ExitCode::SUCCESS) } async fn del( config_path: Option<&Path>, user: &str, name: &str, purge: bool, yes: bool, ) -> anyhow::Result { let (config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; let question = if purge { format!("Permanently delete {user}/{} and its history?", repo.path) } else { format!( "Delete {user}/{}? (its directory moves to trash)", repo.path ) }; if !confirm(&question, yes)? { println!("aborted"); return Ok(ExitCode::SUCCESS); } store.delete_repo(&repo.id).await?; // The database row is gone; now deal with the on-disk directory. A failure // here is logged but does not fail the command — the repo is already // unreachable through fabrica. let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?; if dir.exists() { if purge { if let Err(err) = std::fs::remove_dir_all(&dir) { eprintln!("warning: could not remove {}: {err}", dir.display()); } } else { let trash = config.storage.data_dir.join("trash").join(format!( "{}-{}.git", repo.id, store_now() )); if let Some(parent) = trash.parent() { let _ = std::fs::create_dir_all(parent); } if let Err(err) = std::fs::rename(&dir, &trash) { eprintln!("warning: could not move {} to trash: {err}", dir.display()); } } } println!("deleted repository {user}/{}", repo.path); Ok(ExitCode::SUCCESS) } async fn list( config_path: Option<&Path>, user: Option<&str>, json: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; // Resolve owner id → username once for display. let repos = match user { Some(name) => { let owner_id = require_user_id(&store, name).await?; store.repos_by_owner(&owner_id).await? } None => store.list_repos().await?, }; // Map owner ids to usernames for the owner column. let mut owners = std::collections::HashMap::new(); for repo in &repos { if !owners.contains_key(&repo.owner_id) { let username = store .user_by_id(&repo.owner_id) .await? .map_or_else(|| repo.owner_id.clone(), |u| u.username); owners.insert(repo.owner_id.clone(), username); } } if json { let view: Vec<_> = repos .iter() .map(|r| repo_json(r, &owners[&r.owner_id])) .collect(); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(ExitCode::SUCCESS); } if repos.is_empty() { println!("no repositories"); return Ok(ExitCode::SUCCESS); } for repo in &repos { println!( "{}/{} [{}]", owners[&repo.owner_id], repo.path, repo.visibility ); } Ok(ExitCode::SUCCESS) } async fn rename( config_path: Option<&Path>, user: &str, name: &str, new_name: &str, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; // The group prefix is preserved; only the leaf changes. let new_path = match split_group_path(&repo.path).0 { Some(group) => format!("{group}/{new_name}"), None => new_name.to_string(), }; store.rename_repo(&repo.id, new_name, &new_path).await?; println!("renamed {user}/{} to {user}/{new_path}", repo.path); Ok(ExitCode::SUCCESS) } async fn visibility( config_path: Option<&Path>, user: &str, name: &str, flags: (bool, bool, bool), ) -> anyhow::Result { if flags == (false, false, false) { anyhow::bail!("specify --private, --internal, or --public"); } let (_config, store) = open_store(config_path).await?; // A concrete flag is set, so the default is never consulted here. let visibility = resolve_visibility(flags, config::DefaultVisibility::Private); let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; store.set_visibility(&repo.id, visibility).await?; println!("{user}/{} is now {visibility}", repo.path); Ok(ExitCode::SUCCESS) } async fn collab_add( config_path: Option<&Path>, user: &str, name: &str, collaborator: &str, permission: &str, ) -> anyhow::Result { if !matches!(permission, "read" | "write" | "admin") { anyhow::bail!("permission must be read, write, or admin"); } let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; let target_id = require_user_id(&store, collaborator).await?; if target_id == owner_id { anyhow::bail!("the owner already has full access; not adding them as a collaborator"); } store .set_collaborator(&repo.id, &target_id, permission) .await?; println!( "{collaborator} is now a {permission} collaborator on {user}/{}", repo.path ); Ok(ExitCode::SUCCESS) } async fn collab_rm( config_path: Option<&Path>, user: &str, name: &str, collaborator: &str, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; let target_id = require_user_id(&store, collaborator).await?; if store.remove_collaborator(&repo.id, &target_id).await? { println!("removed {collaborator} from {user}/{}", repo.path); } else { println!( "{collaborator} was not a collaborator on {user}/{}", repo.path ); } Ok(ExitCode::SUCCESS) } async fn collab_list( config_path: Option<&Path>, user: &str, name: &str, json: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; let collaborators = store.list_collaborators(&repo.id).await?; if json { let view: Vec<_> = collaborators .iter() .map(|c| serde_json::json!({ "username": c.username, "permission": c.permission })) .collect(); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(ExitCode::SUCCESS); } if collaborators.is_empty() { println!("no collaborators"); return Ok(ExitCode::SUCCESS); } for c in &collaborators { println!("{} [{}]", c.username, c.permission); } Ok(ExitCode::SUCCESS) } async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result { let (config, store) = open_store(config_path).await?; let owner_id = require_user_id(&store, user).await?; let repo = require_repo(&store, &owner_id, name, user).await?; let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?; println!("{}", dir.display()); Ok(ExitCode::SUCCESS) } /// A millisecond timestamp for trash-directory names. Kept local so the module has /// no direct dependency on the store's private `now_ms`. fn store_now() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) } /// Render a repo for `--json`. fn repo_json(repo: &Repo, owner: &str) -> serde_json::Value { serde_json::json!({ "id": repo.id, "owner": owner, "name": repo.name, "path": repo.path, "description": repo.description, "visibility": repo.visibility.as_str(), "default_branch": repo.default_branch, "created_at": repo.created_at, }) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; use crate::testutil::env; #[test] fn add_creates_row_and_bare_repo_under_a_group() { let env = env(); block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap(); // A grouped repo auto-creates the intermediate group and a bare repo dir. block_on(add( env.path(), "ada", "backend/svc", (true, false, false), None, None, )) .unwrap(); let (repo, dir) = block_on(async { let store = env.store().await; let owner = require_user_id(&store, "ada").await?; let repo = require_repo(&store, &owner, "backend/svc", "ada").await?; let group = store.group_by_owner_path(&owner, "backend").await?; assert!(group.is_some(), "intermediate group should exist"); let cfg = config::load(env.path())?.config; let dir = git::repo_path(&cfg.storage.repo_dir, &repo.id)?; anyhow::Ok((repo, dir)) }) .unwrap(); assert_eq!(repo.path, "backend/svc"); assert_eq!(repo.visibility, model::Visibility::Private); assert!(dir.join("HEAD").exists(), "a bare repo was created on disk"); // Rename preserves the group prefix. block_on(rename(env.path(), "ada", "backend/svc", "service")).unwrap(); let renamed = block_on(async { let store = env.store().await; let owner = require_user_id(&store, "ada").await?; anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?) }) .unwrap(); assert!(renamed.is_some()); // Delete moves the directory to trash (not --purge). block_on(del(env.path(), "ada", "backend/service", false, true)).unwrap(); assert!(!dir.exists(), "the directory moved out to trash"); let gone = block_on(async { let store = env.store().await; let owner = require_user_id(&store, "ada").await?; anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?) }) .unwrap(); assert!(gone.is_none()); } #[test] fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() { let env = env(); block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap(); block_on(add( env.path(), "eve", "proj", (true, false, false), None, None, )) .unwrap(); // A second create at the same id path is impossible, but a duplicate // logical repo must conflict at the store layer and leave nothing behind. let dup = block_on(add( env.path(), "eve", "proj", (true, false, false), None, None, )); assert!(dup.is_err()); } }