use crate::rpcgit::refs::types::{ListRefsOptions, RefType}; use models::dir::dirs::{self, DirKind}; use models::repo::repo_collaborations; use models::repo::repo_license; use models::repo::repos; use models::space::spaces; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait, Unchanged, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; use crate::context::facade::GitContext; use crate::service::GitService; use crate::share::errors::{GitError, map_baby}; use crate::share::validate::{validate_branch_name, validate_repo_name}; pub const DEFAULT_BRANCH: &str = "main"; /// 仓库页签中可由设置重排序的中间页签(代码固定最前、设置固定最后)。 pub const REORDERABLE_REPO_TABS: [&str; 4] = ["commits", "pulls", "branches", "releases"]; /// 归一化页签顺序:只保留已知中间页签、去重、限制长度;空 = 默认顺序。 pub fn normalize_repo_tab_order(order: &[String]) -> Vec { let mut out: Vec = Vec::new(); for id in order { let id = id.trim(); if REORDERABLE_REPO_TABS.contains(&id) && !out.iter().any(|kept| kept == id) { out.push(id.to_string()); } if out.len() == REORDERABLE_REPO_TABS.len() { break; } } out } fn tab_order_from_json(value: Option<&serde_json::Value>) -> Vec { value .and_then(|v| serde_json::from_value::>(v.clone()).ok()) .map(|order| normalize_repo_tab_order(&order)) .unwrap_or_default() } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct RepoDto { pub id: Uuid, pub name: String, pub description: Option, pub website: Option, pub default_branch: String, pub watchers_count: i32, pub stars_count: i32, pub pulls_count: i32, pub commits_count: i32, pub branches_count: i32, pub tags_count: i32, pub enable_lfs: bool, pub is_private: bool, pub is_archived: bool, pub storage_limit_size: Option, pub size: i64, pub git_size: i64, pub lfs_size: i64, pub avatar_url: Option, pub primary_language: Option, pub license_name: Option, pub object_format: String, pub http_clone_url: Option, pub ssh_clone_url: Option, /// 是否允许在本仓库创建合并请求(关闭后页签隐藏、接口拒绝)。 pub allow_pull_requests: bool, /// 中间页签顺序;空数组 = 默认顺序。 pub tab_order: Vec, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, pub last_pushed_at: Option, pub archived_at: Option, #[serde(default)] pub me: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct RepoMeDto { pub starred: bool, pub watch_mode: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct RepoSummaryDto { pub id: Uuid, pub name: String, pub description: Option, pub default_branch: String, pub stars_count: i32, pub watchers_count: i32, pub is_private: bool, pub is_archived: bool, pub size: i64, pub avatar_url: Option, pub primary_language: Option, pub license_name: Option, pub updated_at: OffsetDateTime, } impl From for RepoSummaryDto { fn from(m: repos::Model) -> Self { Self { id: m.id, name: m.name, description: m.description, default_branch: m .default_branch .strip_prefix("refs/heads/") .unwrap_or(&m.default_branch) .to_string(), stars_count: m.nums_star, watchers_count: m.nums_watch, is_private: m.is_private, is_archived: m.is_archived, size: m.size, avatar_url: m.avatar_url, primary_language: m.primary_language, license_name: m.license_name, updated_at: m.updated_at, } } } impl From for RepoDto { fn from(m: repos::Model) -> Self { Self { id: m.id, name: m.name, description: m.description, website: m.website, default_branch: m .default_branch .strip_prefix("refs/heads/") .unwrap_or(&m.default_branch) .to_string(), watchers_count: m.nums_watch, stars_count: m.nums_star, pulls_count: m.nums_pr, commits_count: m.nums_commits, branches_count: m.nums_branches, tags_count: m.nums_tags, enable_lfs: m.enable_lfs, is_private: m.is_private, is_archived: m.is_archived, storage_limit_size: m.storage_limit_size, size: m.size, git_size: m.git_size, lfs_size: m.lfs_size, avatar_url: m.avatar_url, primary_language: m.primary_language, license_name: m.license_name, object_format: m.object_format, http_clone_url: None, ssh_clone_url: None, allow_pull_requests: m.allow_pull_requests, tab_order: tab_order_from_json(m.tab_order.as_ref()), created_at: m.created_at, updated_at: m.updated_at, last_pushed_at: m.last_pushed_at, archived_at: m.archived_at, me: None, } } } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateRepoCmd { pub name: String, #[serde(default)] pub description: Option, #[serde(default)] pub is_private: bool, #[serde(default)] pub default_branch: Option, #[serde(default)] pub parent_dir_id: Option, #[serde(default)] pub enable_lfs: bool, #[serde(default)] pub license_name: Option, #[serde(default)] pub init_message: Option, } #[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)] pub struct UpdateRepoCmd { #[serde(default)] pub description: Option, #[serde(default)] pub website: Option, #[serde(default)] pub is_private: Option, #[serde(default)] pub enable_lfs: Option, #[serde(default)] pub default_branch: Option, #[serde(default)] pub allow_pull_requests: Option, /// 中间页签顺序;传空数组恢复默认顺序。 #[serde(default)] pub tab_order: Option>, } /// Strip a trailing `:` from a URL authority (`localhost:3000` -> `localhost`). /// Bracketed IPv6 literals and non-numeric suffixes are left untouched. fn strip_authority_port(authority: &str) -> &str { match authority.rsplit_once(':') { Some((host, port)) if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) && !port.is_empty() => { host } _ => authority, } } impl GitService { pub fn http_clone_url(&self, space: &str, repo: &str) -> Option { let cfg = &self.state.config.git; let base = if cfg.http_clone_base_url.is_empty() { cfg.base_url.as_str() } else { cfg.http_clone_base_url.as_str() }; if base.is_empty() { return None; } Some(format!("{}/{space}/{repo}.git", base.trim_end_matches('/'))) } pub fn ssh_clone_url(&self, space: &str, repo: &str) -> Option { let cfg = &self.state.config.git; let host = if cfg.ssh_clone_host.is_empty() { // Derive from base_url; the HTTP port is not the SSH port, so drop it. strip_authority_port( cfg.base_url .trim_start_matches("https://") .trim_start_matches("http://") .split('/') .next() .unwrap_or(""), ) .to_string() } else { cfg.ssh_clone_host.clone() }; if host.is_empty() { return None; } let port = if cfg.ssh_clone_port == 0 { cfg.ssh.port } else { cfg.ssh_clone_port }; let user = if cfg.ssh_clone_user.is_empty() { "git" } else { cfg.ssh_clone_user.as_str() }; Some(if port == 22 { format!("{user}@{host}:{space}/{repo}.git") } else { format!("ssh://{user}@{host}:{port}/{space}/{repo}.git") }) } /// 补齐 `RepoDto::from` 拿不到的派生字段:克隆地址(依赖空间名)。 /// /// 合并成一个入口,是因为仓库详情有四处出口(读取、更新、归档、取消归档), /// 分开填充必然出现某处漏掉、界面在保存后忽然少一块信息的情况。 pub fn fill_derived(&self, dto: &mut RepoDto, space_name: &str) { let repo_name = dto.name.clone(); dto.http_clone_url = self.http_clone_url(space_name, &repo_name); dto.ssh_clone_url = self.ssh_clone_url(space_name, &repo_name); } pub async fn repo_dto(&self, m: repos::Model) -> Result { let space = crate::share::access::space_for_dir(self.db_read(), m.dir_id) .await? .ok_or(GitError::NotFound)?; let mut dto = RepoDto::from(m); self.fill_derived(&mut dto, &space.name); Ok(dto) } async fn lookup_space(&self, space_name: &str) -> Result { let lower = space_name.trim().to_lowercase(); if lower.is_empty() { return Err(GitError::NotFound); } spaces::Entity::find() .filter(spaces::Column::LowerName.eq(&lower)) .filter(spaces::Column::IsActive.eq(true)) .one(self.db_read()) .await? .ok_or(GitError::NotFound) } pub async fn create_repo( &self, space_name: String, caller: i64, cmd: CreateRepoCmd, ) -> Result { let space = self.lookup_space(&space_name).await?; if space.owner_id != caller { return Err(GitError::Forbidden); } let name = validate_repo_name(&cmd.name)?; let default_branch = validate_branch_name(cmd.default_branch.as_deref().unwrap_or(DEFAULT_BRANCH))?; if let Some(p) = cmd.parent_dir_id { let parent = dirs::Entity::find_by_id(p) .filter(dirs::Column::SpaceId.eq(space.id)) .one(self.db_read()) .await? .ok_or(GitError::NotFound)?; if parent.kind != DirKind::Dir { return Err(GitError::InvalidArgument( "parent node must be a dir".to_string(), )); } } if let Some(name) = cmd.license_name.as_deref() { let exists = repo_license::Entity::find_by_id(name.to_string()) .one(self.db_read()) .await? .is_some(); if !exists { return Err(GitError::InvalidArgument(format!( "license `{name}` is not registered" ))); } } let (dir, repo) = { let txn = self.db_write().begin().await?; let position = { let max_node = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id)) .order_by_desc(dirs::Column::Position) .lock(sea_orm::sea_query::LockType::Update) .one(&txn) .await?; max_node.map(|n| n.position + 1).unwrap_or(0) }; let now = OffsetDateTime::now_utc(); let dir_row = dirs::ActiveModel { id: Set(Uuid::now_v7()), space_id: Set(Some(space.id)), name: Set(name.clone()), kind: Set(DirKind::Object), parent_dir_id: Set(cmd.parent_dir_id), color: Set(None), position: Set(position), icon: Set(None), topic: Set(None), purpose: Set(None), is_archived: Set(false), archived_at: Set(None), archived_by: Set(None), created_at: Set(now), updated_at: Set(now), created_by_uid: Set(caller), }; let dir = dir_row.insert(&txn).await?; let repo_row = repos::ActiveModel { id: Set(Uuid::now_v7()), dir_id: Set(dir.id), lower_name: Set(name.to_lowercase()), name: Set(name), description: Set(cmd.description.clone()), website: Set(None), default_branch: Set(default_branch.clone()), nums_watch: Set(0), nums_star: Set(0), nums_pr: Set(0), nums_commits: Set(0), nums_branches: Set(0), nums_tags: Set(0), enable_lfs: Set(cmd.enable_lfs), is_private: Set(cmd.is_private), is_archived: Set(false), storage_limit_size: Set(None), size: Set(0), git_size: Set(0), lfs_size: Set(0), avatar_url: Set(None), primary_language: Set(None), license_name: Set(cmd.license_name.clone()), created_at: Set(now), updated_at: Set(now), last_pushed_at: Set(None), archived_at: Set(None), readme_raw: Set(None), allow_rebase: Set(true), allow_rebase_explicit: Set(false), allow_squash_merge: Set(true), allow_merge_commits: Set(true), object_format: Set("sha1".to_string()), allow_pull_requests: Set(true), tab_order: Set(None), }; let repo = repo_row.insert(&txn).await?; txn.commit().await?; (dir, repo) }; let cache = std::sync::Arc::new(cache::CacheClient::new(self.state.redis.clone())); let git_cfg = &self.state.config.git; let ctx = GitContext { model: repo.clone(), cache, database: self.state.database.clone(), space_name: space.lower_name.clone(), repo_root: std::path::PathBuf::from(&git_cfg.repo_root), ks: self.ks().clone(), }; let init_result: Result<(), GitError> = async { self.baby(&ctx) .init_repository("", true, None) .await .map_err(map_baby) } .await; if let Err(err) = init_result { if let Err(e) = repos::Entity::delete_by_id(repo.id) .exec(self.db_write()) .await { tracing::warn!(error = %e, "failed to clean up repo row after init failure"); } if let Err(e) = dirs::Entity::delete_by_id(dir.id) .exec(self.db_write()) .await { tracing::warn!(error = %e, "failed to clean up dir row after init failure"); } return Err(err); } self.sync_repo_stats(&ctx).await?; let fresh = repos::Entity::find_by_id(repo.id) .one(self.db_read()) .await? .ok_or(GitError::NotFound)?; let mut dto = RepoDto::from(fresh); self.fill_derived(&mut dto, &space.name); Ok(dto) } pub async fn get_repo( &self, space_name: String, repo_name: String, ) -> Result { let ctx = self.context(space_name, repo_name).await?; self.repo_dto(ctx.model).await } pub async fn repo_viewer_state(&self, repo_id: Uuid, uid: i64) -> Result { use models::repo::repo_star; use models::repo::repo_watch; let starred = repo_star::Entity::find() .filter(repo_star::Column::RepoId.eq(repo_id)) .filter(repo_star::Column::UserId.eq(uid)) .one(self.db_read()) .await? .is_some(); let watch_mode = repo_watch::Entity::find() .filter(repo_watch::Column::RepoId.eq(repo_id)) .filter(repo_watch::Column::UserId.eq(uid)) .one(self.db_read()) .await? .map(|w| w.mode); Ok(RepoMeDto { starred, watch_mode, }) } pub async fn get_repo_for_html( &self, ctx: std::sync::Arc, ) -> Result { self.repo_dto(ctx.model.clone()).await } pub async fn list_repos( &self, space_name: String, caller: Option, page: export::pagination::PageQuery, ) -> Result, GitError> { let space = self.lookup_space(&space_name).await?; let dirs = dirs::Entity::find() .filter(dirs::Column::SpaceId.eq(space.id)) .filter(dirs::Column::Kind.eq(DirKind::Object)) .all(self.db_read()) .await?; let dir_ids: Vec = dirs.iter().map(|d| d.id).collect(); let mut sel = repos::Entity::find().filter(repos::Column::DirId.is_in(dir_ids)); sel = match page.sort.as_deref() { Some("created_at") => sel.order_by_asc(repos::Column::CreatedAt), Some("-updated_at") => sel.order_by_desc(repos::Column::UpdatedAt), Some("updated_at") => sel.order_by_asc(repos::Column::UpdatedAt), Some("stars") => sel.order_by_desc(repos::Column::NumsStar), Some("name") => sel.order_by_asc(repos::Column::Name), _ => sel.order_by_asc(repos::Column::CreatedAt), }; let rows = sel.all(self.db_read()).await?; let owner_view = caller.is_some_and(|uid| space.owner_id == uid); let mut accessible: std::collections::HashSet = std::collections::HashSet::new(); if let Some(uid) = caller && !owner_view { let repo_ids: Vec = rows.iter().map(|r| r.id).collect(); let collabs = repo_collaborations::Entity::find() .filter(repo_collaborations::Column::UserId.eq(uid)) .filter(repo_collaborations::Column::RepoId.is_in(repo_ids)) .all(self.db_read()) .await?; accessible.extend(collabs.into_iter().map(|c| c.repo_id)); } let visible: Vec = rows .into_iter() .filter(|r| !r.is_private || owner_view || accessible.contains(&r.id)) .map(RepoSummaryDto::from) .collect(); let total = visible.len() as i64; let (limit, offset) = page.page(); let items = visible .into_iter() .skip(offset as usize) .take(limit as usize) .collect(); Ok(export::pagination::Page { items, total, limit, offset, }) } pub async fn update_repo( &self, space_name: String, repo_name: String, caller: i64, cmd: UpdateRepoCmd, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let space = self.repo_space(&ctx).await?; if space.owner_id != caller { return Err(GitError::Forbidden); } let new_branch = match &cmd.default_branch { Some(b) => Some(validate_branch_name(b)?), None => None, }; let old_branch = ctx.model.default_branch.clone(); let now = OffsetDateTime::now_utc(); let mut am = repos::ActiveModel { id: Unchanged(ctx.model.id), ..Default::default() }; if let Some(desc) = cmd.description { am.description = Set(Some(desc)); } if let Some(website) = cmd.website { am.website = Set(Some(website)); } if let Some(private) = cmd.is_private { am.is_private = Set(private); } if let Some(lfs) = cmd.enable_lfs { am.enable_lfs = Set(lfs); } if let Some(branch) = &new_branch { am.default_branch = Set(branch.clone()); } // 页签公开性与合并请求开关互相独立:只传其中一个时不动其余。 if let Some(value) = cmd.allow_pull_requests { am.allow_pull_requests = Set(value); } if let Some(order) = &cmd.tab_order { am.tab_order = Set(Some(serde_json::json!(normalize_repo_tab_order(order)))); } am.updated_at = Set(now); let saved = am.update(self.db_write()).await?; if let Some(branch) = new_branch && branch != old_branch { let baby = self.baby(&ctx); if let Err(e) = baby.set_default_branch(&branch).await { let revert = repos::ActiveModel { id: Unchanged(ctx.model.id), default_branch: Set(old_branch), updated_at: Set(now), ..Default::default() }; let _ = revert.update(self.db_write()).await; return Err(map_baby(e)); } } let mut dto = RepoDto::from(saved); self.fill_derived(&mut dto, &space.name); Ok(dto) } pub async fn set_repo_archived( &self, space_name: String, repo_name: String, caller: i64, archived: bool, ) -> Result { let ctx = self.context(space_name, repo_name).await?; let space = self.repo_space(&ctx).await?; if space.owner_id != caller { return Err(GitError::Forbidden); } if ctx.model.is_archived == archived { return Err(GitError::Conflict(if archived { "repo is already archived".to_string() } else { "repo is not archived".to_string() })); } let now = OffsetDateTime::now_utc(); let am = repos::ActiveModel { id: Unchanged(ctx.model.id), is_archived: Set(archived), archived_at: Set(archived.then_some(now)), updated_at: Set(now), ..Default::default() }; let saved = am.update(self.db_write()).await?; let mut dto = RepoDto::from(saved); self.fill_derived(&mut dto, &space.name); Ok(dto) } async fn repo_space(&self, ctx: &GitContext) -> Result { crate::share::access::space_for_dir(self.db_read(), ctx.model.dir_id) .await? .ok_or(GitError::NotFound) } pub async fn sync_repo_stats(&self, ctx: &GitContext) -> Result<(), GitError> { let baby = self.baby(ctx); let refs = baby .list_refs(ListRefsOptions::default()) .await .map_err(map_baby)?; let branches_count = refs .iter() .filter(|r| r.ref_type == RefType::Branch) .count() as i32; let tags_count = refs.iter().filter(|r| r.ref_type == RefType::Tag).count() as i32; let commits_count = baby .count_commits(crate::rpcgit::commit::types::ListCommitsOptions { revision: "--all".to_string(), ..Default::default() }) .await .unwrap_or(0) as i32; let git_size = baby.repo_size().await.unwrap_or(0) as i64; let now = OffsetDateTime::now_utc(); let am = repos::ActiveModel { id: Unchanged(ctx.model.id), nums_branches: Set(branches_count), nums_tags: Set(tags_count), nums_commits: Set(commits_count), git_size: Set(git_size), updated_at: Set(now), ..Default::default() }; am.update(self.db_write()).await?; Ok(()) } } #[cfg(test)] mod tests { use super::strip_authority_port; #[test] fn strips_http_port_but_keeps_host() { assert_eq!(strip_authority_port("localhost:3000"), "localhost"); assert_eq!( strip_authority_port("git.example.com:8443"), "git.example.com" ); assert_eq!(strip_authority_port("[::1]:3000"), "[::1]"); } #[test] fn leaves_host_without_numeric_port_intact() { assert_eq!(strip_authority_port("localhost"), "localhost"); assert_eq!(strip_authority_port("localhost:"), "localhost:"); assert_eq!(strip_authority_port("[::1]"), "[::1]"); assert_eq!(strip_authority_port(""), ""); } }