use models::git::git_lfs_lock; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use time::OffsetDateTime; use uuid::Uuid; use crate::lfs::types::{LfsLock, LfsLockListResponse, LfsLockOwner, LfsVerifyLocksResponse}; use crate::lfs::validate::{canonical_oid, validate_lock_path}; use crate::service::GitService; use crate::share::errors::GitError; const DEFAULT_LOCK_LIMIT: u64 = 100; const MAX_LOCK_LIMIT: u64 = 100; pub async fn create( svc: &GitService, space_name: String, repo_name: String, caller: i64, path: String, oid: Option, ) -> Result { let ctx = svc.context(space_name, repo_name).await?; if !ctx.model.enable_lfs { return Err(GitError::NotFound); } let path = validate_lock_path(&path)?.to_string(); let oid = match oid { Some(raw) => Some(canonical_oid(&raw)?), None => None, }; let now = OffsetDateTime::now_utc(); let row = git_lfs_lock::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(ctx.model.id), path: Set(path.clone()), owner_id: Set(caller), oid: Set(oid), created_at: Set(now), }; let inserted: git_lfs_lock::Model = row.insert(svc.db_write()).await?; Ok(LfsLock { id: inserted.id.to_string(), path: inserted.path, locked_at: format_time(inserted.created_at), owner: LfsLockOwner { name: owner_name(svc, caller).await, }, }) } pub async fn unlock( svc: &GitService, space_name: String, repo_name: String, caller: i64, path: String, force: bool, ) -> Result<(), GitError> { let ctx = svc.context(space_name, repo_name).await?; let row = git_lfs_lock::Entity::find() .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id)) .filter(git_lfs_lock::Column::Path.eq(&path)) .one(svc.db_write()) .await? .ok_or(GitError::NotFound)?; if row.owner_id != caller && !force { return Err(GitError::Forbidden); } git_lfs_lock::Entity::delete_by_id(row.id) .exec(svc.db_write()) .await?; Ok(()) } /// Unlock by lock id (the shape used by the LFS HTTP API: /// `POST locks/{id}/unlock`). Same ownership/`force` rules as [`unlock`]. pub async fn unlock_by_id( svc: &GitService, space_name: String, repo_name: String, caller: i64, lock_id: String, force: bool, ) -> Result<(), GitError> { let ctx = svc.context(space_name, repo_name).await?; let id = Uuid::parse_str(&lock_id).map_err(|_| GitError::NotFound)?; let row = git_lfs_lock::Entity::find() .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id)) .filter(git_lfs_lock::Column::Id.eq(id)) .one(svc.db_write()) .await? .ok_or(GitError::NotFound)?; if row.owner_id != caller && !force { return Err(GitError::Forbidden); } git_lfs_lock::Entity::delete_by_id(row.id) .exec(svc.db_write()) .await?; Ok(()) } /// List locks, oldest first, capped by `limit` (default/max 100). When the /// page is full, `next_cursor` carries the last `locked_at` RFC3339 value so /// the client can page forward. pub async fn list( svc: &GitService, space_name: String, repo_name: String, cursor: Option, limit: u64, ) -> Result { let ctx = svc.context(space_name, repo_name).await?; let limit = normalize_limit(limit); let mut find = git_lfs_lock::Entity::find() .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id)) .order_by_asc(git_lfs_lock::Column::CreatedAt); if let Some(cursor) = cursor { let at = parse_cursor(&cursor)?; find = find.filter(git_lfs_lock::Column::CreatedAt.gt(at)); } let mut rows = find.limit(limit + 1).all(svc.db_read()).await?; let has_more = rows.len() as u64 > limit; rows.truncate(limit as usize); let locks = to_locks(svc, rows).await; let next_cursor = if has_more { locks.last().map(|l| l.locked_at.clone()) } else { None }; Ok(LfsLockListResponse { locks, next_cursor }) } /// Split this repo's locks into `ours` (locked by the caller) and `theirs`. pub async fn verify_locks( svc: &GitService, space_name: String, repo_name: String, caller: i64, limit: u64, ) -> Result { let ctx = svc.context(space_name, repo_name).await?; let rows = git_lfs_lock::Entity::find() .filter(git_lfs_lock::Column::RepoId.eq(ctx.model.id)) .order_by_asc(git_lfs_lock::Column::CreatedAt) .limit(normalize_limit(limit)) .all(svc.db_read()) .await?; let mut ours = Vec::new(); let mut theirs = Vec::new(); for row in rows { let lock = LfsLock { id: row.id.to_string(), path: row.path, locked_at: format_time(row.created_at), owner: LfsLockOwner { name: owner_name(svc, row.owner_id).await, }, }; if row.owner_id == caller { ours.push(lock); } else { theirs.push(lock); } } Ok(LfsVerifyLocksResponse { ours, theirs, next_cursor: None, }) } async fn to_locks(svc: &GitService, rows: Vec) -> Vec { let mut locks = Vec::with_capacity(rows.len()); for row in rows { locks.push(LfsLock { id: row.id.to_string(), path: row.path, locked_at: format_time(row.created_at), owner: LfsLockOwner { name: owner_name(svc, row.owner_id).await, }, }); } locks } async fn owner_name(svc: &GitService, uid: i64) -> String { match export::users::get_public_user(&svc.state, uid).await { Ok(Some(user)) if !user.displayname.trim().is_empty() => user.displayname, Ok(Some(user)) => user.username, _ => uid.to_string(), } } fn format_time(at: OffsetDateTime) -> String { at.format(&time::format_description::well_known::Rfc3339) .unwrap_or_default() } fn parse_cursor(cursor: &str) -> Result { OffsetDateTime::parse(cursor, &time::format_description::well_known::Rfc3339) .map_err(|e| GitError::InvalidArgument(format!("invalid cursor: {e}"))) } fn normalize_limit(limit: u64) -> u64 { match limit { 0 => DEFAULT_LOCK_LIMIT, n if n > MAX_LOCK_LIMIT => MAX_LOCK_LIMIT, n => n, } }