//! Git repository CRUD and lookup queries. use chrono::{DateTime, Utc}; use sqlx::{FromRow, PgPool}; use super::models::DbGitRepo; use super::{GitRepoId, GitRepoKind, ProjectId, UserId, Visibility}; use crate::error::Result; /// A public repo joined with its owner's username, for the explore page. #[derive(FromRow)] pub struct PublicRepoWithOwner { pub name: String, pub description: String, pub created_at: DateTime, pub owner_username: String, } /// Register a new git repository for a user (default visibility: public). /// /// Validates `name` against `validate_git_repo_name` as a defense-in-depth /// backstop, the SSH dispatch path and HTTP smart-protocol path both call /// this with names supplied by untrusted remote git clients. #[tracing::instrument(skip_all)] pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result { crate::validation::validate_creatable_repo_name(name)?; let repo = sqlx::query_as::<_, DbGitRepo>( r" INSERT INTO git_repos (user_id, name) VALUES ($1, $2) RETURNING * ", ) .bind(user_id) .bind(name) .fetch_one(pool) .await?; Ok(repo) } /// Register a new git repository with explicit visibility. #[tracing::instrument(skip_all)] pub async fn create_repo_with_visibility( pool: &PgPool, user_id: UserId, name: &str, visibility: Visibility, ) -> Result { crate::validation::validate_creatable_repo_name(name)?; let repo = sqlx::query_as::<_, DbGitRepo>( r" INSERT INTO git_repos (user_id, name, visibility) VALUES ($1, $2, $3) RETURNING * ", ) .bind(user_id) .bind(name) .bind(visibility) .fetch_one(pool) .await?; Ok(repo) } /// The account's annotation repository, if it has one yet. #[tracing::instrument(skip_all)] pub async fn get_annotation_repo(pool: &PgPool, user_id: UserId) -> Result> { let repo = sqlx::query_as::<_, DbGitRepo>( "SELECT * FROM git_repos WHERE user_id = $1 AND kind = 'annotations'", ) .bind(user_id) .fetch_optional(pool) .await?; Ok(repo) } /// Register the account's annotation repository. /// /// Private and `kind = 'annotations'` in one statement rather than an insert /// plus two updates: a row that is public for the width of a transaction is a /// row that can be read. `ON CONFLICT DO NOTHING` plus a re-select, so two /// first-annotations racing each other both end up with the row the winner /// made. /// /// Deliberately does not call `validate_creatable_repo_name`: the name it /// writes is the reserved one, and this is the code the reservation exists for. #[tracing::instrument(skip_all)] pub async fn create_annotation_repo(pool: &PgPool, user_id: UserId) -> Result { let inserted = sqlx::query_as::<_, DbGitRepo>( r" INSERT INTO git_repos (user_id, name, visibility, kind, description) VALUES ($1, $2, 'private', 'annotations', $3) ON CONFLICT (user_id, name) DO NOTHING RETURNING * ", ) .bind(user_id) .bind(crate::constants::ANNOTATION_REPO_NAME) .bind(ANNOTATION_REPO_DESCRIPTION) .fetch_optional(pool) .await?; if let Some(repo) = inserted { return Ok(repo); } // Somebody else inserted it, or the account already owns a repository of // that name. Either way the row that is there is the answer. get_repo_by_user_and_name(pool, user_id, crate::constants::ANNOTATION_REPO_NAME) .await? .ok_or_else(|| { crate::error::AppError::Internal(anyhow::anyhow!( "annotation repo insert conflicted but no row is there" )) }) } /// What the annotation repository says on its own page. pub const ANNOTATION_REPO_DESCRIPTION: &str = "Personal annotations. Notes on commits across makenot.work, private to this account."; /// Whether this repository is the account's annotation store, which is private /// permanently: publishing a set of annotations is a moderation and consent /// decision, not a toggle. pub fn is_annotation_repo(repo: &DbGitRepo) -> bool { repo.kind == GitRepoKind::Annotations } /// Look up a repo by its primary key. Returns `None` if not found. #[tracing::instrument(skip_all)] pub async fn get_repo_by_id(pool: &PgPool, repo_id: GitRepoId) -> Result> { let repo = sqlx::query_as::<_, DbGitRepo>("SELECT * FROM git_repos WHERE id = $1") .bind(repo_id) .fetch_optional(pool) .await?; Ok(repo) } /// Look up a repo by its owning user and bare name. Returns `None` if not found. #[tracing::instrument(skip_all)] pub async fn get_repo_by_user_and_name( pool: &PgPool, user_id: UserId, name: &str, ) -> Result> { let repo = sqlx::query_as::<_, DbGitRepo>("SELECT * FROM git_repos WHERE user_id = $1 AND name = $2") .bind(user_id) .bind(name) .fetch_optional(pool) .await?; Ok(repo) } /// List all repos owned by a user, newest first. #[tracing::instrument(skip_all)] pub async fn get_repos_by_user(pool: &PgPool, user_id: UserId) -> Result> { let repos = sqlx::query_as::<_, DbGitRepo>( "SELECT * FROM git_repos WHERE user_id = $1 ORDER BY created_at DESC LIMIT 500", ) .bind(user_id) .fetch_all(pool) .await?; Ok(repos) } /// List public repos owned by a user, newest first. #[tracing::instrument(skip_all)] pub async fn get_public_repos_by_user(pool: &PgPool, user_id: UserId) -> Result> { let repos = sqlx::query_as::<_, DbGitRepo>( "SELECT * FROM git_repos WHERE user_id = $1 AND visibility = 'public' ORDER BY created_at DESC LIMIT 500", ) .bind(user_id) .fetch_all(pool) .await?; Ok(repos) } /// List all repos linked to a specific project. #[tracing::instrument(skip_all)] pub async fn get_repos_by_project(pool: &PgPool, project_id: ProjectId) -> Result> { let repos = sqlx::query_as::<_, DbGitRepo>( "SELECT * FROM git_repos WHERE project_id = $1 ORDER BY name ASC", ) .bind(project_id) .fetch_all(pool) .await?; Ok(repos) } /// Link a repo to a project (sets `project_id`). #[tracing::instrument(skip_all)] pub async fn link_repo_to_project( pool: &PgPool, repo_id: GitRepoId, project_id: ProjectId, ) -> Result<()> { sqlx::query("UPDATE git_repos SET project_id = $2 WHERE id = $1") .bind(repo_id) .bind(project_id) .execute(pool) .await?; Ok(()) } /// Unlink a repo from its project (sets `project_id = NULL`). #[tracing::instrument(skip_all)] pub async fn unlink_repo_from_project(pool: &PgPool, repo_id: GitRepoId) -> Result<()> { sqlx::query("UPDATE git_repos SET project_id = NULL WHERE id = $1") .bind(repo_id) .execute(pool) .await?; Ok(()) } /// Update the visibility of a repo. #[tracing::instrument(skip_all)] pub async fn update_visibility( pool: &PgPool, repo_id: GitRepoId, visibility: Visibility, ) -> Result<()> { sqlx::query("UPDATE git_repos SET visibility = $2 WHERE id = $1") .bind(repo_id) .bind(visibility) .execute(pool) .await?; Ok(()) } /// Update description and visibility in one call. #[tracing::instrument(skip_all)] pub async fn update_repo_settings( pool: &PgPool, repo_id: GitRepoId, description: &str, visibility: Visibility, ) -> Result<()> { sqlx::query("UPDATE git_repos SET description = $2, visibility = $3 WHERE id = $1") .bind(repo_id) .bind(description) .bind(visibility) .execute(pool) .await?; Ok(()) } /// Delete a repo from the database (leaves files on disk for safety). #[tracing::instrument(skip_all)] pub async fn delete_repo(pool: &PgPool, repo_id: GitRepoId) -> Result<()> { sqlx::query("DELETE FROM git_repos WHERE id = $1") .bind(repo_id) .execute(pool) .await?; Ok(()) } /// List all public repos across all users, newest first, with owner username. #[tracing::instrument(skip_all)] pub async fn get_all_public_repos( pool: &PgPool, limit: i64, offset: i64, ) -> Result> { let repos = sqlx::query_as::<_, PublicRepoWithOwner>( r" SELECT g.name, g.description, g.created_at, u.username AS owner_username FROM git_repos g JOIN users u ON u.id = g.user_id WHERE g.visibility = 'public' ORDER BY g.created_at DESC LIMIT $1 OFFSET $2 ", ) .bind(limit) .bind(offset) .fetch_all(pool) .await?; Ok(repos) } /// Count all public repos (for pagination). #[tracing::instrument(skip_all)] pub async fn count_all_public_repos(pool: &PgPool) -> Result { let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM git_repos WHERE visibility = 'public'") .fetch_one(pool) .await?; Ok(count.0) } /// Every repository on the platform, with its owner's username. /// /// Visibility-blind on purpose: the caller is an operator rebuilding a /// server-side projection of what is on disk, not a page deciding what to show. /// Nothing user-facing should reach for this. #[tracing::instrument(skip_all)] pub async fn all_repos_with_owner(pool: &PgPool) -> Result> { let rows = sqlx::query_as::<_, (GitRepoId, String, String)>( r" SELECT g.id, u.username, g.name FROM git_repos g JOIN users u ON u.id = g.user_id ORDER BY u.username, g.name ", ) .fetch_all(pool) .await?; Ok(rows) } #[cfg(test)] mod tests { #[test] fn annotation_repo_name_is_a_legal_repo_name_and_reserved() { let name = crate::constants::ANNOTATION_REPO_NAME; assert!(crate::validation::validate_git_repo_name(name).is_ok()); assert!(crate::validation::is_reserved_repo_name(name)); assert!(crate::validation::validate_creatable_repo_name(name).is_err()); } }