From 30822bbd7df77f0090eafb8379e79677950df556 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Wed, 29 Apr 2026 09:03:22 +0800 Subject: [PATCH] fix(skill): support bare repo scanning via git tree traversal Add scan_repo_tree_for_skills and scan_skills_from_tree functions that traverse git objects directly instead of filesystem, enabling skill discovery in bare repositories created via git2::Repository::init_bare. --- libs/git/hook/sync/mod.rs | 109 +++++++++++++++++++++++++++------- libs/service/skill/scanner.rs | 83 ++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 24 deletions(-) diff --git a/libs/git/hook/sync/mod.rs b/libs/git/hook/sync/mod.rs index a97464d..6f1bc14 100644 --- a/libs/git/hook/sync/mod.rs +++ b/libs/git/hook/sync/mod.rs @@ -132,6 +132,65 @@ fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) { } } +/// Scan git tree objects for `SKILL.md` files (works for bare repos). +fn scan_skills_from_tree( + git_repo: &git2::Repository, + repo_id: &RepoId, + commit_sha: &str, +) -> Result, String> { + let repo_id_prefix = &repo_id.to_string()[..8]; + let head = git_repo.head().map_err(|e| format!("no HEAD: {e}"))?; + let tree = head.peel_to_tree().map_err(|e| format!("no tree: {e}"))?; + + let mut discovered = Vec::new(); + let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())]; + + while let Some((current_tree, prefix)) = stack.pop() { + for entry in current_tree.iter() { + let name = match entry.name() { + Some(n) => n, + None => continue, + }; + let entry_path = if prefix.is_empty() { + name.to_string() + } else { + format!("{}/{}", prefix, name) + }; + + match entry.kind() { + Some(git2::ObjectType::Tree) => { + if !name.starts_with('.') { + if let Ok(subtree) = entry.to_object(git_repo).and_then(|o| o.peel_to_tree()) { + stack.push((subtree, entry_path)); + } + } + } + Some(git2::ObjectType::Blob) if name.to_lowercase() == "skill.md" => { + let dir_name = std::path::Path::new(&entry_path) + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .filter(|s| !s.starts_with('.')); + let Some(dir_name) = dir_name else { continue }; + + let slug = format!("{}/{}", repo_id_prefix, dir_name); + if let Ok(blob) = entry.to_object(git_repo).and_then(|o| o.peel_to_blob()) { + let raw = blob.content(); + 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) +} + #[derive(Clone)] pub struct HookMetaDataSync { pub db: AppDatabase, @@ -275,15 +334,9 @@ impl HookMetaDataSync { /// Best-effort — failures are logged but do not fail the sync. pub async fn sync_skills(&self) { let project_uid = self.repo.project; + let git_repo = self.domain.repo(); - let repo_root = match self.domain.repo().workdir() { - Some(p) => p.to_path_buf(), - None => return, - }; - - let commit_sha = self - .domain - .repo() + let commit_sha = git_repo .head() .ok() .and_then(|h| h.target()) @@ -291,19 +344,35 @@ impl HookMetaDataSync { .unwrap_or_default(); let repo_id = self.repo.id; - let discovered = match tokio::task::spawn_blocking(move || { - scan_skills_from_dir(&repo_root, &repo_id, &commit_sha) - }) - .await - { - Ok(Ok(d)) => d, - Ok(Err(e)) => { - tracing::warn!("failed to scan skills directory error={}", e); - return; + let is_bare = git_repo.is_bare() || git_repo.workdir().is_none(); + + let discovered = if is_bare { + // Bare repo: scan git tree objects directly + let git_repo_ref = self.domain.repo(); + match scan_skills_from_tree(git_repo_ref, &repo_id, &commit_sha) { + Ok(d) => d, + Err(e) => { + tracing::warn!("failed to scan skills from tree error={}", e); + return; + } } - Err(e) => { - tracing::warn!("spawn_blocking join error error={}", e); - return; + } else { + // Normal repo: walk filesystem + let repo_root = git_repo.workdir().unwrap().to_path_buf(); + match tokio::task::spawn_blocking(move || { + scan_skills_from_dir(&repo_root, &repo_id, &commit_sha) + }) + .await + { + Ok(Ok(d)) => d, + Ok(Err(e)) => { + tracing::warn!("failed to scan skills directory error={}", e); + return; + } + Err(e) => { + tracing::warn!("spawn_blocking join error error={}", e); + return; + } } }; diff --git a/libs/service/skill/scanner.rs b/libs/service/skill/scanner.rs index c4aba5c..62fe721 100644 --- a/libs/service/skill/scanner.rs +++ b/libs/service/skill/scanner.rs @@ -90,7 +90,7 @@ fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) { } } -/// Recursively scan `repo_path` for `SKILL.md` files. +/// Recursively scan `repo_path` for `SKILL.md` files (filesystem walk, non-bare repos). /// The skill slug is `{short_repo_id}/{parent_dir_name}` to ensure uniqueness across repos. pub fn scan_repo_for_skills( repo_path: &Path, @@ -135,8 +135,72 @@ pub fn scan_repo_for_skills( Ok(discovered) } +/// Scan git tree objects for `SKILL.md` files (works for bare repos). +/// Traverses the HEAD commit tree using libgit2, reading blob content from objects. +pub fn scan_repo_tree_for_skills( + git_repo: &Repository, + repo_id: Uuid, +) -> Result, AppError> { + let repo_id_prefix = &repo_id.to_string()[..8]; + let head = git_repo + .head() + .map_err(|e| AppError::InternalServerError(format!("no HEAD: {e}")))?; + let tree = head + .peel_to_tree() + .map_err(|e| AppError::InternalServerError(format!("no tree: {e}")))?; + + let mut discovered = Vec::new(); + // Stack: (tree, path_prefix relative to root) + let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())]; + + while let Some((current_tree, prefix)) = stack.pop() { + for entry in current_tree.iter() { + let name = match entry.name() { + Some(n) => n, + None => continue, + }; + let entry_path = if prefix.is_empty() { + name.to_string() + } else { + format!("{}/{}", prefix, name) + }; + + match entry.kind() { + Some(git2::ObjectType::Tree) => { + if !name.starts_with('.') { + if let Ok(subtree) = entry.to_object(git_repo).and_then(|o| o.peel_to_tree()) { + stack.push((subtree, entry_path)); + } + } + } + Some(git2::ObjectType::Blob) if name.to_lowercase() == "skill.md" => { + // Derive skill name from parent directory + let dir_name = std::path::Path::new(&entry_path) + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .filter(|s| !s.starts_with('.')); + let Some(dir_name) = dir_name else { continue }; + + let slug = format!("{}/{}", repo_id_prefix, dir_name); + if let Ok(blob) = entry.to_object(git_repo).and_then(|o| o.peel_to_blob()) { + let raw = blob.content(); + let blob_hash = git_blob_hash(raw); + let mut skill = parse_skill_file(&slug, &String::from_utf8_lossy(raw)); + skill.blob_hash = Some(blob_hash); + discovered.push(skill); + } + } + _ => {} + } + } + } + + Ok(discovered) +} + /// Scan a git2::Repository for skills and upsert them into the database. -/// Called from the git hook sync path. +/// Uses filesystem walk for normal repos, git tree traversal for bare repos. pub async fn scan_and_sync_skills( db: &db::database::AppDatabase, project_uuid: Uuid, @@ -156,10 +220,21 @@ pub async fn scan_and_sync_skills( } }; - let workdir = git_repo.workdir().map(|p| p.to_path_buf()).unwrap_or_else(|| Path::new(&repo.storage_path).to_path_buf()); let commit_sha = git_repo.head().ok().and_then(|h| h.target()).map(|oid| oid.to_string()); - let mut discovered = scan_repo_for_skills(&workdir, repo.id)?; + // For bare repos (no workdir), scan git tree objects directly + let mut discovered = if git_repo.is_bare() || git_repo.workdir().is_none() { + match scan_repo_tree_for_skills(&git_repo, repo.id) { + Ok(skills) => skills, + Err(e) => { + tracing::warn!("tree scan failed for repo {}: {:?}", repo.storage_path, e); + vec![] + } + } + } else { + let workdir = git_repo.workdir().unwrap(); + scan_repo_for_skills(workdir, repo.id)? + }; // Fill in commit_sha for discovered skills for skill in &mut discovered {