pub mod branch; pub mod commit; pub mod fsck; pub mod gc; pub mod lfs; pub mod tag; use db::cache::AppCache; use db::database::AppDatabase; use models::projects::project_skill::{Column as SkillCol, Entity as SkillEntity}; use models::projects::project_skill::ActiveModel as SkillActiveModel; use models::repos::repo::Model as RepoModel; use models::RepoId; use models::ActiveModelTrait; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; use slog::Logger; use std::collections::HashMap; use std::path::Path; use crate::GitDomain; // ── Skill discovery (local, no service crate dependency) ──────────────────────── use sha1::Digest; /// Recursively scan `base` for files named `SKILL.md`. /// The skill slug is `{short_repo_id}/{parent_dir_name}` to ensure uniqueness across repos. /// Populates `commit_sha` (current HEAD) and `blob_hash` for each discovered file. fn scan_skills_from_dir( base: &Path, repo_id: &RepoId, commit_sha: &str, ) -> Result, std::io::Error> { let repo_id_prefix = &repo_id.to_string()[..8]; let mut discovered = Vec::new(); let mut stack = vec![base.to_path_buf()]; while let Some(dir) = stack.pop() { let entries = match std::fs::read_dir(&dir) { Ok(e) => e, Err(_) => continue, }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { stack.push(path); } else if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") { if let Some(dir_name) = path.parent() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .filter(|s| !s.starts_with('.')) { let slug = format!("{}/{}", repo_id_prefix, dir_name); if let Ok(raw) = std::fs::read(&path) { let blob_hash = git_blob_hash(&raw); let mut skill = parse_skill_content(&slug, &raw); skill.commit_sha = Some(commit_sha.to_string()); skill.blob_hash = Some(blob_hash); discovered.push(skill); } } } } } Ok(discovered) } /// Compute the git blob SHA-1 hash of `content`. /// Format: "blob {len}\0{data}" fn git_blob_hash(content: &[u8]) -> String { let size = content.len(); let header = format!("blob {}\0", size); let mut hasher = sha1::Sha1::new(); hasher.update(header.as_bytes()); hasher.update(content); hex::encode(hasher.finalize()) } /// Parse a SKILL.md file (raw bytes) to extract name, description, content, and frontmatter metadata. fn parse_skill_content(slug: &str, raw: &[u8]) -> DiscoveredSkill { let content = String::from_utf8_lossy(raw); let (frontmatter, body) = extract_frontmatter(&content); let metadata: serde_json::Value = frontmatter .and_then(|fm| serde_json::from_str(fm).ok()) .unwrap_or_default(); let name = metadata .get("name") .and_then(|v| v.as_str()) .map(String::from) .unwrap_or_else(|| slug.replace('-', " ").replace('_', " ")); let description = metadata .get("description") .and_then(|v| v.as_str()) .map(String::from); DiscoveredSkill { slug: slug.to_string(), name, description, content: body.trim().to_string(), metadata, commit_sha: None, blob_hash: None, } } /// A skill discovered in a repository. struct DiscoveredSkill { slug: String, name: String, description: Option, content: String, metadata: serde_json::Value, commit_sha: Option, blob_hash: Option, } fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) { let trimmed = raw.trim_start(); if !trimmed.starts_with("---") { return (None, trimmed); } if let Some(end) = trimmed[3..].find("---") { let fm = &trimmed[3..end + 3]; let rest = trimmed[3 + end + 3..].trim_start(); (Some(fm), rest) } else { (None, trimmed) } } #[derive(Clone)] pub struct HookMetaDataSync { pub db: AppDatabase, pub cache: AppCache, pub repo: RepoModel, pub domain: GitDomain, pub logger: Logger, } impl HookMetaDataSync { pub fn new( db: AppDatabase, cache: AppCache, repo: RepoModel, logger: Logger, ) -> Result { let domain = GitDomain::from_model(repo.clone())?; Ok(Self { db, cache, repo, domain, logger, }) } /// Full sync: refs → commits → tags → LFS → fsck → gc → skills. /// No distributed lock — K8s StatefulSet scheduling guarantees exclusive access. pub async fn sync(&self) -> Result<(), crate::GitError> { // All git2 operations must run on a blocking thread since git2 types are not Send. let db = self.db.clone(); let cache = self.cache.clone(); let repo = self.repo.clone(); let logger = self.logger.clone(); let res = tokio::task::spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async move { let sync = Self::new(db, cache, repo, logger)?; let out = sync.sync_full().await; if let Err(ref e) = out { slog::error!(sync.logger, "sync failed: {}", e); } out }) }) .await .map_err(|e| crate::GitError::Internal(format!("spawn_blocking join error: {}", e)))??; Ok(res) } /// Run fsck only (refs snapshot + git fsck + rollback on corruption). pub async fn fsck_only(&self) -> Result<(), crate::GitError> { let db = self.db.clone(); let cache = self.cache.clone(); let repo = self.repo.clone(); let logger = self.logger.clone(); tokio::task::spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async move { let sync = Self::new(db, cache, repo, logger)?; let mut txn = sync.db.begin().await.map_err(|e| { crate::GitError::IoError(format!("failed to begin transaction: {}", e)) })?; sync.run_fsck_and_rollback_if_corrupt(&mut txn).await?; txn.commit().await.map_err(|e| { crate::GitError::IoError(format!("failed to commit transaction: {}", e)) })?; Ok::<(), crate::GitError>(()) }) }) .await .map_err(|e| crate::GitError::Internal(format!("spawn_blocking join error: {}", e)))??; Ok(()) } /// Run gc only. pub async fn gc_only(&self) -> Result<(), crate::GitError> { let db = self.db.clone(); let cache = self.cache.clone(); let repo = self.repo.clone(); let logger = self.logger.clone(); tokio::task::spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async move { let sync = Self::new(db, cache, repo, logger)?; sync.run_gc().await }) }) .await .map_err(|e| crate::GitError::Internal(format!("spawn_blocking join error: {}", e)))??; Ok(()) } /// Full sync pipeline inside a single DB transaction. /// On fsck failure, refs are rolled back and an error is returned. async fn sync_full(&self) -> Result<(), crate::GitError> { let mut txn = self .db .begin() .await .map_err(|e| crate::GitError::IoError(format!("failed to begin transaction: {}", e)))?; self.sync_refs(&mut txn).await?; self.sync_commits(&mut txn).await?; self.sync_tags(&mut txn).await?; self.sync_lfs_objects(&mut txn).await?; self.run_fsck_and_rollback_if_corrupt(&mut txn) .await?; txn.commit().await.map_err(|e| { crate::GitError::IoError(format!("failed to commit transaction: {}", e)) })?; self.run_gc().await?; self.sync_skills().await; Ok(()) } /// Returns a list of (branch_name, oid) for all local branches. pub fn list_branch_tips(&self) -> Vec<(String, String)> { let repo = self.domain.repo(); let mut tips = Vec::new(); if let Ok(refs) = repo.references() { for ref_result in refs { if let Ok(r) = ref_result { if r.is_branch() && !r.is_remote() { if let Some(name) = r.name() { let branch = name.strip_prefix("refs/heads/").unwrap_or(name); if let Some(target) = r.target() { tips.push((branch.to_string(), target.to_string())); } } } } } } tips } /// Returns a list of (tag_name, oid) for all tags. pub fn list_tag_tips(&self) -> Vec<(String, String)> { let repo = self.domain.repo(); let mut tips = Vec::new(); if let Ok(refs) = repo.references() { for ref_result in refs { if let Ok(r) = ref_result { if r.is_tag() { if let Some(name) = r.name() { let tag = name.strip_prefix("refs/tags/").unwrap_or(name); if let Some(target) = r.target() { tips.push((tag.to_string(), target.to_string())); } } } } } } tips } /// Scan the repository for `SKILL.md` files and sync skills to the project. /// Best-effort — failures are logged but do not fail the sync. pub async fn sync_skills(&self) { let project_uid = self.repo.project; let repo_root = match self.domain.repo().workdir() { Some(p) => p, None => return, }; let commit_sha = self .domain .repo() .head() .ok() .and_then(|h| h.target()) .map(|oid| oid.to_string()) .unwrap_or_default(); let discovered = match scan_skills_from_dir(repo_root, &self.repo.id, &commit_sha) { Ok(d) => d, Err(e) => { slog::warn!(self.logger, "failed to scan skills directory: {}", e); return; } }; if discovered.is_empty() { return; } let now = chrono::Utc::now(); let mut created = 0i64; let mut updated = 0i64; let mut removed = 0i64; let existing: Vec<_> = match SkillEntity::find() .filter(SkillCol::ProjectUuid.eq(project_uid)) .filter(SkillCol::Source.eq("repo")) .filter(SkillCol::RepoId.eq(self.repo.id)) .all(&self.db) .await { Ok(e) => e, Err(e) => { slog::warn!(self.logger, "failed to query existing skills: {}", e); return; } }; let existing_by_slug: HashMap<_, _> = existing .into_iter() .map(|s| (s.slug.clone(), s)) .collect(); let mut seen_slugs = std::collections::HashSet::new(); for skill in discovered { seen_slugs.insert(skill.slug.clone()); let json_meta = serde_json::to_value(&skill.metadata).unwrap_or_default(); if let Some(existing_skill) = existing_by_slug.get(&skill.slug) { if existing_skill.content != skill.content || existing_skill.metadata != json_meta || existing_skill.commit_sha.as_ref() != skill.commit_sha.as_ref() || existing_skill.blob_hash.as_ref() != skill.blob_hash.as_ref() { let mut active: SkillActiveModel = existing_skill.clone().into(); active.content = Set(skill.content); active.metadata = Set(json_meta); active.commit_sha = Set(skill.commit_sha); active.blob_hash = Set(skill.blob_hash); active.updated_at = Set(now); if active.update(&self.db).await.is_ok() { updated += 1; } } } else { let active = SkillActiveModel { id: Set(0), project_uuid: Set(project_uid), slug: Set(skill.slug.clone()), name: Set(skill.name), description: Set(skill.description), source: Set("repo".to_string()), repo_id: Set(Some(self.repo.id)), commit_sha: Set(skill.commit_sha), blob_hash: Set(skill.blob_hash), content: Set(skill.content), metadata: Set(json_meta), enabled: Set(true), created_by: Set(None), created_at: Set(now), updated_at: Set(now), }; if SkillEntity::insert(active).exec(&self.db).await.is_ok() { created += 1; } } } for (slug, old_skill) in existing_by_slug { if !seen_slugs.contains(&slug) { if SkillEntity::delete_by_id(old_skill.id).exec(&self.db).await.is_ok() { removed += 1; } } } if created > 0 || updated > 0 || removed > 0 { slog::info!( self.logger, "skills synced: created={}, updated={}, removed={}", created, updated, removed ); } } }