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.
This commit is contained in:
parent
b673c31485
commit
30822bbd7d
@ -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<Vec<DiscoveredSkill>, 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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<Vec<DiscoveredSkill>, 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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user