Compare commits

..

No commits in common. "bba35f1b2c339fd654b057268c930c9fd8aef9e1" and "c2b45535372b2c865d4c778d907d4b94508e8b4b" have entirely different histories.

8 changed files with 32 additions and 1729 deletions

View File

@ -685,30 +685,17 @@ impl ChatService {
for call in &calls { for call in &calls {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let executor = crate::tool::ToolExecutor::new();
// Spawn tool execution in a separate task to avoid blocking the // Use select! loop to send heartbeat chunks at 30s intervals
// tokio worker thread (git2 operations are synchronous). // during long tool execution, resetting the frontend streaming timer.
// This allows the heartbeat timer to fire independently. let fut = executor.execute_batch(vec![call.clone()], &mut ctx);
let call_clone = call.clone(); tokio::pin!(fut);
let mut ctx_clone = ctx.clone();
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let executor = crate::tool::ToolExecutor::new();
let res = executor.execute_batch(vec![call_clone], &mut ctx_clone).await;
let _ = result_tx.send(res);
});
// Send heartbeats every 10s until tool execution completes
let heartbeat_dur = std::time::Duration::from_secs(10);
let results = loop { let results = loop {
tokio::select! { tokio::select! {
res = &mut result_rx => { result = fut.as_mut() => break result,
match res { _ = tokio::time::sleep(std::time::Duration::from_secs(30)) => {
Ok(inner) => break inner,
Err(_) => break Err(crate::tool::ToolError::ExecutionError("tool task cancelled".into())),
}
},
_ = tokio::time::sleep(heartbeat_dur) => {
on_chunk(AiStreamChunk { on_chunk(AiStreamChunk {
content: String::new(), content: String::new(),
done: false, done: false,
@ -921,17 +908,6 @@ impl ChatService {
async fn build_messages(&self, request: &AiRequest) -> Result<Vec<ChatRequestMessage>> { async fn build_messages(&self, request: &AiRequest) -> Result<Vec<ChatRequestMessage>> {
let mut messages = Vec::new(); let mut messages = Vec::new();
// Core reasoning instruction — prioritize analysis before answering.
messages.push(ChatRequestMessage::system(
"When receiving a question or problem, follow this reasoning process:\n\
1. ANALYZE: Break down the question. Identify what is being asked, what context is available, and what information is missing.\n\
2. GATHER: Use available tools (repository search, file reading, etc.) to collect relevant information before answering.\n\
3. REASON: Synthesize the gathered information. Consider edge cases and potential issues.\n\
4. ANSWER: Provide a clear, actionable answer based on your analysis.\n\
\n\
Do NOT guess or assume when tools can provide concrete answers. Always verify claims against actual code or documentation.".to_string()
));
let mut processed_history = Vec::new(); let mut processed_history = Vec::new();
if let Some(compact_service) = &self.compact_service { if let Some(compact_service) = &self.compact_service {
let config = CompactConfig::default(); let config = CompactConfig::default();

View File

@ -1,384 +0,0 @@
//! Knowledge-base (documentation) repository tools for AI.
//!
//! Provides tools for AI to quickly index, read, and search
//! through documentation / knowledge-base repositories.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
// ── Helpers ────────────────────────────────────────────────────────────────────
/// Extract frontmatter (--- ... ---) from markdown content.
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)
}
}
/// Extract all headings (lines starting with #) from markdown body.
fn extract_headings(body: &str) -> Vec<serde_json::Value> {
body.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("# ") {
Some(serde_json::json!({ "level": 1, "text": trimmed[2..].trim() }))
} else if trimmed.starts_with("## ") {
Some(serde_json::json!({ "level": 2, "text": trimmed[3..].trim() }))
} else if trimmed.starts_with("### ") {
Some(serde_json::json!({ "level": 3, "text": trimmed[4..].trim() }))
} else if trimmed.starts_with("#### ") {
Some(serde_json::json!({ "level": 4, "text": trimmed[5..].trim() }))
} else {
None
}
})
.collect()
}
/// Resolve HEAD to a tree for traversal.
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
}
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Tool: repo_doc_index — list all markdown docs with frontmatter
async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut docs = 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('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let raw = String::from_utf8_lossy(blob.content());
let (fm_raw, body) = extract_frontmatter(&raw);
let metadata: serde_json::Value = fm_raw
.and_then(|fm| serde_json::from_str(fm).ok())
.unwrap_or_default();
let title = metadata
.get("title")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
// Fall back to first # heading
body.lines()
.find(|l| l.trim().starts_with("# "))
.map(|l| l.trim()[2..].trim().to_string())
});
let description = metadata
.get("description")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
// Fall back to first non-heading non-empty line
body.lines()
.find(|l| {
let t = l.trim();
!t.is_empty() && !t.starts_with('#')
})
.map(|l| l.trim().chars().take(200).collect::<String>())
});
let tags: Vec<String> = metadata
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let headings = extract_headings(body);
docs.push(serde_json::json!({
"path": entry_path,
"title": title,
"description": description,
"tags": tags,
"headings": headings,
"size": raw.len(),
}));
}
}
}
_ => {}
}
}
}
// Sort by path for consistent ordering
docs.sort_by(|a, b| {
a["path"].as_str().unwrap_or("").cmp(b["path"].as_str().unwrap_or(""))
});
Ok(serde_json::json!({
"total": docs.len(),
"docs": docs
}))
}
/// Tool: repo_doc_read — read a specific document with structure
async fn repo_doc_read_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Navigate to the file using git2 path lookup
let entry = tree.get_path(std::path::Path::new(path))
.map_err(|e| format!("file '{}' not found: {e}", path))?;
let blob = entry.to_object(repo).and_then(|o| o.peel_to_blob())
.map_err(|e| format!("not a blob: {e}"))?;
let raw = String::from_utf8_lossy(blob.content());
let (fm_raw, body) = extract_frontmatter(&raw);
let metadata: serde_json::Value = fm_raw
.and_then(|fm| serde_json::from_str(fm).ok())
.unwrap_or_default();
let title = metadata
.get("title")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
body.lines()
.find(|l| l.trim().starts_with("# "))
.map(|l| l.trim()[2..].trim().to_string())
});
let headings = extract_headings(body);
Ok(serde_json::json!({
"path": path,
"title": title,
"metadata": metadata,
"headings": headings,
"content": body.to_string(),
"size": raw.len(),
}))
}
/// Tool: repo_doc_search — search through docs content
async fn repo_doc_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?;
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
let keyword_lower = keyword.to_lowercase();
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut matches: Vec<serde_json::Value> = Vec::new();
let mut matched_files = 0u64;
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('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") || name.ends_with(".txt") {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content());
let lines: Vec<&str> = content.lines().collect();
let mut file_hits: Vec<serde_json::Value> = Vec::new();
let mut hit_lines = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.to_lowercase().contains(&keyword_lower) {
hit_lines.push(i);
}
}
if !hit_lines.is_empty() {
matched_files += 1;
// Merge overlapping context windows
let mut windows: Vec<(usize, usize)> = Vec::new();
for &line_idx in &hit_lines {
let start = line_idx.saturating_sub(context_lines);
let end = (line_idx + context_lines + 1).min(lines.len());
if let Some(last) = windows.last_mut() {
if start <= last.1 {
last.1 = end;
continue;
}
}
windows.push((start, end));
}
for (start, end) in windows {
let snippet: Vec<String> = lines[start..end]
.iter()
.map(|l| l.to_string())
.collect();
file_hits.push(serde_json::json!({
"line_start": start + 1,
"line_end": end,
"snippet": snippet.join("\n"),
}));
}
matches.push(serde_json::json!({
"path": entry_path,
"hit_count": hit_lines.len(),
"snippets": file_hits,
}));
}
}
}
}
_ => {}
}
}
}
Ok(serde_json::json!({
"keyword": keyword,
"matched_files": matched_files,
"total_hits": matches.iter().map(|m| m["hit_count"].as_u64().unwrap_or(0)).sum::<u64>(),
"matches": matches,
}))
}
// ── Registration ───────────────────────────────────────────────────────────────
macro_rules! param {
($name:expr, $type:expr, $desc:expr, $required:expr) => {
(
$name.into(),
ToolParam {
name: $name.into(),
param_type: $type.into(),
description: Some($desc.into()),
required: $required,
properties: None,
items: None,
},
)
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// repo_doc_index
registry.register(
ToolDefinition::new("repo_doc_index")
.description("Index all documentation files in a knowledge-base repository. Lists every .md/.mdx file with its title, description, tags, and heading structure. Use this first to understand what documents are available.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_doc_index_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_doc_read
registry.register(
ToolDefinition::new("repo_doc_read")
.description("Read a specific document from a knowledge-base repository. Returns the full markdown content plus extracted frontmatter metadata and heading structure. Use this after repo_doc_index to read the documents you need.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("path", "string", "Document file path within the repository", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_doc_read_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_doc_search
registry.register(
ToolDefinition::new("repo_doc_search")
.description("Search through all documentation files in a knowledge-base repository for a keyword. Returns matching file paths, hit counts, and context snippets. Use this to find which documents discuss a specific topic.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("keyword", "string", "Search keyword (case-insensitive)", true),
param!("context_lines", "integer", "Number of context lines around each match (default: 2)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "keyword".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_doc_search_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -8,9 +8,6 @@ pub mod branch;
pub mod commit; pub mod commit;
pub mod ctx; pub mod ctx;
pub mod diff; pub mod diff;
pub mod kb;
pub mod repo_analysis;
pub mod repo_util;
pub mod tag; pub mod tag;
pub mod tree; pub mod tree;
pub mod types; pub mod types;
@ -23,7 +20,4 @@ pub fn register_all(registry: &mut agent::ToolRegistry) {
blob::register_git_tools(registry); blob::register_git_tools(registry);
tree::register_git_tools(registry); tree::register_git_tools(registry);
tag::register_git_tools(registry); tag::register_git_tools(registry);
repo_analysis::register_git_tools(registry);
kb::register_git_tools(registry);
repo_util::register_git_tools(registry);
} }

View File

@ -1,627 +0,0 @@
//! Repository analysis tools for AI.
//!
//! Provides function-calling tools that let AI quickly understand
//! repository structure, languages, dependencies, and overview.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
// ── Helpers ────────────────────────────────────────────────────────────────────
/// Recognised dependency manifest file names and their parser labels.
const DEPENDENCY_MANIFESTS: &[(&str, &str)] = &[
("Cargo.toml", "rust"),
("package.json", "node"),
("go.mod", "go"),
("go.sum", "go"),
("Gemfile", "ruby"),
("requirements.txt", "python"),
("Pipfile", "python"),
("pyproject.toml", "python"),
("pom.xml", "java"),
("build.gradle", "java"),
("build.gradle.kts", "java"),
("composer.json", "php"),
("CMakeLists.txt", "cmake"),
("Makefile", "make"),
];
/// Language detection by file extension (lowercase).
fn ext_to_language(ext: &str) -> Option<&'static str> {
match ext {
"rs" => Some("Rust"),
"go" => Some("Go"),
"py" => Some("Python"),
"js" => Some("JavaScript"),
"jsx" => Some("JSX"),
"ts" => Some("TypeScript"),
"tsx" => Some("TSX"),
"java" => Some("Java"),
"kt" | "kts" => Some("Kotlin"),
"rb" => Some("Ruby"),
"php" => Some("PHP"),
"c" => Some("C"),
"h" => Some("C/C++ Header"),
"cpp" | "cc" | "cxx" => Some("C++"),
"hpp" | "hh" => Some("C++ Header"),
"cs" => Some("C#"),
"swift" => Some("Swift"),
"scala" => Some("Scala"),
"zig" => Some("Zig"),
"sh" | "bash" | "zsh" => Some("Shell"),
"ps1" => Some("PowerShell"),
"sql" => Some("SQL"),
"html" | "htm" => Some("HTML"),
"css" | "scss" | "sass" | "less" => Some("CSS"),
"json" => Some("JSON"),
"yaml" | "yml" => Some("YAML"),
"toml" => Some("TOML"),
"md" => Some("Markdown"),
"dockerfile" | "containerfile" => Some("Dockerfile"),
"proto" => Some("Protobuf"),
"vue" => Some("Vue"),
"svelte" => Some("Svelte"),
"lua" => Some("Lua"),
"dart" => Some("Dart"),
"r" | "R" => Some("R"),
"clj" | "cljs" | "cljc" => Some("Clojure"),
"ex" | "exs" => Some("Elixir"),
"erl" => Some("Erlang"),
"hs" => Some("Haskell"),
_ => None,
}
}
/// Directories that should be ignored in file-tree scans.
fn is_ignored_dir(name: &str) -> bool {
matches!(
name,
".git" | "node_modules" | "target" | "dist" | "build" | ".next"
| ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox"
| "vendor" | ".bundle" | ".gradle" | "bin" | "obj"
| ".svn" | ".hg" | ".idea" | ".vscode" | "coverage"
| ".terraform" | ".serverless" | "deps" | "_build"
| "elm-stuff" | ".stack-work" | ".pytest_cache"
)
}
/// Recursively collect file extensions and counts from a git tree.
/// Skips ignored directories and binary-looking files.
fn collect_languages(
repo: &git2::Repository,
tree: &git2::Tree,
prefix: &str,
stats: &mut HashMap<String, (String, u64)>,
max_files: u64,
) {
let mut count = 0u64;
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree.clone(), prefix.to_string())];
while let Some((current_tree, path)) = stack.pop() {
for entry in current_tree.iter() {
if max_files > 0 && count >= max_files {
return;
}
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if path.is_empty() {
name.to_string()
} else {
format!("{}/{}", path, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !is_ignored_dir(name) && !name.starts_with('.') {
if let Ok(subtree) =
entry.to_object(repo).and_then(|o| o.peel_to_tree())
{
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
count += 1;
if let Some(ext) = name.rsplit('.').next() {
let ext = ext.to_lowercase();
if let Some(lang) = ext_to_language(&ext) {
let entry = stats
.entry(lang.to_string())
.or_insert_with(|| (ext, 0));
entry.1 += 1;
}
}
}
_ => {}
}
}
}
}
/// Collect a recursive file tree (path + kind) up to a given depth and file limit.
fn collect_file_tree(
repo: &git2::Repository,
tree: &git2::Tree,
prefix: &str,
depth: usize,
max_depth: usize,
max_files: u64,
files: &mut Vec<serde_json::Value>,
) {
if depth > max_depth {
return;
}
for entry in tree.iter() {
if max_files > 0 && files.len() as u64 >= max_files {
return;
}
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 !is_ignored_dir(name) && !name.starts_with('.') {
files.push(serde_json::json!({
"path": entry_path,
"kind": "dir"
}));
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
collect_file_tree(repo, &subtree, &entry_path, depth + 1, max_depth, max_files, files);
}
}
}
Some(git2::ObjectType::Blob) => {
files.push(serde_json::json!({
"path": entry_path,
"kind": "file"
}));
}
_ => {}
}
}
}
/// Detect config/manifest files in the root tree and return their names.
fn detect_config_files(tree: &git2::Tree) -> Vec<String> {
let mut configs = Vec::new();
let known_configs = [
"Cargo.toml", "package.json", "go.mod", "Gemfile", "README.md",
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
".github/workflows", ".gitignore", ".gitattributes",
"Makefile", "CMakeLists.txt", "composer.json", "pyproject.toml",
"requirements.txt", "Pipfile", "pom.xml", "build.gradle",
"build.gradle.kts", "settings.gradle", "settings.gradle.kts",
"tsconfig.json", ".eslintrc.js", ".eslintrc.json",
"prettier.config.js", "prettierrc", "webpack.config.js",
"vite.config.ts", "vite.config.js", "next.config.js",
"nuxt.config.ts", "svelte.config.js",
"rust-toolchain", "rust-toolchain.toml",
"clippy.toml", ".rustfmt.toml", "rustfmt.toml",
"renovate.json", ".renovaterc", ".mergify.yml",
"docker-bake.hcl", ".dockerignore",
"Cargo.lock", "yarn.lock", "package-lock.json", "pnpm-lock.yaml",
"Gemfile.lock", "Cargo.lock",
];
for entry in tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
if known_configs.contains(&name) || name.starts_with('.') && !name.starts_with(".git") {
configs.push(name.to_string());
}
}
configs.sort();
configs.dedup();
configs
}
/// Parse a dependency manifest file content and return a structured summary.
fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value {
match manifest_name {
"Cargo.toml" => {
// Simple TOML-ish parsing for [dependencies] section
let mut deps = Vec::new();
let mut in_deps = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("[dependencies]") {
in_deps = true;
continue;
}
if trimmed.starts_with('[') {
in_deps = false;
continue;
}
if in_deps {
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim().to_string();
let version = trimmed[eq_pos + 1..].trim().trim_matches('"').trim_matches('\'').to_string();
if !name.is_empty() && !name.starts_with('#') {
deps.push(serde_json::json!({ "name": name, "version": version }));
}
} else if !trimmed.is_empty() && !trimmed.starts_with('#') {
// bare dependency name (path/git dep without explicit version)
deps.push(serde_json::json!({ "name": trimmed, "version": null }));
}
}
}
serde_json::json!({ "manifest": "Cargo.toml", "ecosystem": "rust", "dependencies": deps })
}
"package.json" => {
let mut deps = Vec::new();
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
for section in &["dependencies", "devDependencies", "peerDependencies"] {
if let Some(map) = parsed.get(*section).and_then(|v| v.as_object()) {
for (name, version) in map {
deps.push(serde_json::json!({
"name": name,
"version": version.as_str().unwrap_or("*"),
"scope": section
}));
}
}
}
}
serde_json::json!({ "manifest": "package.json", "ecosystem": "node", "dependencies": deps })
}
"go.mod" => {
let mut deps = Vec::new();
let mut in_require = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("require (") || trimmed == "require (" {
in_require = true;
continue;
}
if trimmed == ")" {
in_require = false;
continue;
}
if in_require {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
deps.push(serde_json::json!({ "name": parts[0], "version": parts[1] }));
}
}
}
serde_json::json!({ "manifest": "go.mod", "ecosystem": "go", "dependencies": deps })
}
"Gemfile" => {
let mut deps = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("gem ") {
let rest = trimmed.trim_start_matches("gem ");
let name = rest.split(',').next().unwrap_or(rest).trim().trim_matches('"').trim_matches('\'');
let version = rest.split(',').nth(1).map(|v| v.trim().trim_matches('"').trim_matches('\''));
deps.push(serde_json::json!({ "name": name, "version": version }));
}
}
serde_json::json!({ "manifest": "Gemfile", "ecosystem": "ruby", "dependencies": deps })
}
"requirements.txt" => {
let mut deps = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("-r") && !trimmed.starts_with("--") {
if let Some(eq_eq) = trimmed.find("==") {
let name = trimmed[..eq_eq].trim().to_string();
let version = trimmed[eq_eq + 2..].trim().to_string();
deps.push(serde_json::json!({ "name": name, "version": version }));
} else {
deps.push(serde_json::json!({ "name": trimmed, "version": null }));
}
}
}
serde_json::json!({ "manifest": "requirements.txt", "ecosystem": "python", "dependencies": deps })
}
_ => serde_json::json!({ "manifest": manifest_name, "ecosystem": "unknown", "dependencies": [] }),
}
}
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Resolve HEAD to a tree for traversal.
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
}
/// Resolve HEAD to a commit OID.
fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.target()
.map(|o| o.to_string())
.ok_or_else(|| "HEAD has no target".to_string())
}
/// Tool: repo_overview — quick project overview
async fn repo_overview_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Default branch
let default_branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
// Config files in root
let config_files = detect_config_files(&tree);
// Language stats (up to 5000 files)
let mut lang_stats: HashMap<String, (String, u64)> = HashMap::new();
collect_languages(repo, &tree, "", &mut lang_stats, 5000);
let mut languages: Vec<serde_json::Value> = lang_stats
.into_iter()
.map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count }))
.collect();
languages.sort_by(|a, b| {
b["file_count"].as_u64().unwrap_or(0)
.cmp(&a["file_count"].as_u64().unwrap_or(0))
});
// Top-level file tree
let mut root_files: Vec<serde_json::Value> = Vec::new();
collect_file_tree(repo, &tree, "", 0, 1, 100, &mut root_files);
// Recent commits (last 10)
let head_oid = head_oid(&domain)?;
let recent_commits = domain
.commit_log(Some(&head_oid), 0, 10)
.map_err(|e| e.to_string())?;
let commits: Vec<serde_json::Value> = recent_commits
.iter()
.map(|c| {
serde_json::json!({
"oid": c.oid.to_string(),
"summary": c.summary,
"author": c.author.name,
"time": c.author.time_secs,
})
})
.collect();
// Total commit count
let total_commits = domain.commit_total(Some(&head_oid)).unwrap_or(0);
Ok(serde_json::json!({
"default_branch": default_branch,
"head_oid": head_oid,
"total_commits": total_commits,
"config_files": config_files,
"languages": languages,
"top_level_entries": root_files,
"recent_commits": commits,
}))
}
/// Tool: repo_file_tree — recursive file tree with depth/ignore
async fn repo_file_tree_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let max_depth = p.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
let max_files = p.get("max_files").and_then(|v| v.as_u64()).unwrap_or(200);
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut files = Vec::new();
collect_file_tree(repo, &tree, "", 0, max_depth, max_files, &mut files);
Ok(serde_json::json!({
"total": files.len(),
"entries": files
}))
}
/// Tool: repo_languages — detailed language breakdown
async fn repo_languages_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut lang_stats: HashMap<String, (String, u64)> = HashMap::new();
collect_languages(repo, &tree, "", &mut lang_stats, 100_000);
let mut languages: Vec<serde_json::Value> = lang_stats
.into_iter()
.map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count }))
.collect();
languages.sort_by(|a, b| {
b["file_count"].as_u64().unwrap_or(0)
.cmp(&a["file_count"].as_u64().unwrap_or(0))
});
Ok(serde_json::json!({
"total_languages": languages.len(),
"languages": languages
}))
}
/// Tool: repo_dependencies — parse dependency manifests
async fn repo_dependencies_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let tree = head_tree(&domain)?;
// Walk the tree looking for dependency manifests at any depth
let mut manifests: Vec<serde_json::Value> = Vec::new();
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
let repo = domain.repo();
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 !is_ignored_dir(name) && !name.starts_with('.') {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if DEPENDENCY_MANIFESTS.iter().any(|(fname, _)| *fname == name) {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content());
let manifest_type = DEPENDENCY_MANIFESTS
.iter()
.find(|(fname, _)| *fname == name)
.map(|(_, eco)| eco)
.unwrap_or(&"unknown");
let parsed = parse_dependencies(&content, name);
manifests.push(serde_json::json!({
"path": entry_path,
"ecosystem": manifest_type,
"details": parsed
}));
}
}
}
_ => {}
}
}
}
Ok(serde_json::json!({
"manifest_count": manifests.len(),
"manifests": manifests
}))
}
// ── Registration ───────────────────────────────────────────────────────────────
macro_rules! param {
($name:expr, $type:expr, $desc:expr, $required:expr) => {
(
$name.into(),
ToolParam {
name: $name.into(),
param_type: $type.into(),
description: Some($desc.into()),
required: $required,
properties: None,
items: None,
},
)
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// repo_overview
registry.register(
ToolDefinition::new("repo_overview")
.description("Get a quick overview of a repository: default branch, detected config files, language breakdown by file count, top-level directory entries, and recent commits. Ideal for first contact with a repo.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_overview_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_file_tree
registry.register(
ToolDefinition::new("repo_file_tree")
.description("List files and directories in a repository recursively with configurable depth. Ignores common generated/artifact directories (node_modules, target, .git, etc.). Useful for understanding project layout.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("max_depth", "integer", "Maximum directory depth to traverse (default: 3)", false),
param!("max_files", "integer", "Maximum number of entries to return (default: 200)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_file_tree_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_languages
registry.register(
ToolDefinition::new("repo_languages")
.description("Get a detailed breakdown of programming languages used in a repository, sorted by file count. Scans all files in the repo (up to 100K files) and maps extensions to language names.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_languages_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_dependencies
registry.register(
ToolDefinition::new("repo_dependencies")
.description("Discover and parse dependency manifests (Cargo.toml, package.json, go.mod, Gemfile, requirements.txt, etc.) in a repository. Returns structured dependency lists per manifest.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_dependencies_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -1,512 +0,0 @@
//! General-purpose repository utility tools for AI.
//!
//! Code search, README reading, commit history, contributors, and diff summaries.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
// ── Helpers ────────────────────────────────────────────────────────────────────
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
}
fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.target()
.map(|o| o.to_string())
.ok_or_else(|| "HEAD has no target".to_string())
}
fn is_ignored_dir(name: &str) -> bool {
matches!(
name,
".git" | "node_modules" | "target" | "dist" | "build" | ".next"
| ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox"
| "vendor" | ".bundle" | ".gradle" | "bin" | "obj"
| ".svn" | ".hg" | ".idea" | ".vscode" | "coverage"
| ".terraform" | ".serverless" | "deps" | "_build"
| "elm-stuff" | ".stack-work" | ".pytest_cache"
)
}
fn is_binary_ext(name: &str) -> bool {
match name.rsplit('.').next().unwrap_or("") {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "svg" | "bmp"
| "mp3" | "mp4" | "wav" | "avi" | "mov" | "mkv" | "webm"
| "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar"
| "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib"
| "woff" | "woff2" | "ttf" | "otf" | "eot"
| "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
| "sqlite" | "db" | "bin" | "dat" | "pyc" | "class"
| "wasm" | "node" => true,
_ => false,
}
}
/// Resolve a rev string to a commit OID.
fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> {
domain.resolve_rev(rev).map_err(|e| e.to_string())
}
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Tool: repo_search — search code content across the repo
async fn repo_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?;
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
let max_results = p.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let keyword_lower = keyword.to_lowercase();
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut matches: Vec<serde_json::Value> = Vec::new();
let mut matched_files = 0usize;
let mut total_hits = 0usize;
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
'outer: while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
if matches.len() >= max_results {
break 'outer;
}
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 !is_ignored_dir(name) && !name.starts_with('.') {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if is_binary_ext(name) {
continue;
}
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
if blob.is_binary() {
continue;
}
let content = String::from_utf8_lossy(blob.content());
let lines: Vec<&str> = content.lines().collect();
let mut hit_lines = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.to_lowercase().contains(&keyword_lower) {
hit_lines.push(i);
}
}
if !hit_lines.is_empty() {
matched_files += 1;
total_hits += hit_lines.len();
// Merge overlapping context windows
let mut windows: Vec<(usize, usize)> = Vec::new();
for &line_idx in &hit_lines {
let start = line_idx.saturating_sub(context_lines);
let end = (line_idx + context_lines + 1).min(lines.len());
if let Some(last) = windows.last_mut() {
if start <= last.1 {
last.1 = end;
continue;
}
}
windows.push((start, end));
}
let snippets: Vec<serde_json::Value> = windows
.iter()
.map(|(start, end)| {
let snippet: Vec<String> = lines[*start..*end]
.iter()
.map(|l| l.to_string())
.collect();
serde_json::json!({
"line_start": start + 1,
"line_end": end,
"snippet": snippet.join("\n"),
})
})
.collect();
matches.push(serde_json::json!({
"path": entry_path,
"hit_count": hit_lines.len(),
"snippets": snippets,
}));
}
}
}
_ => {}
}
}
}
Ok(serde_json::json!({
"keyword": keyword,
"matched_files": matched_files,
"total_hits": total_hits,
"results": matches,
}))
}
/// Tool: repo_readme — get README content
async fn repo_readme_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Try common README filenames
let candidates = ["README.md", "README.MD", "README.markdown", "README.rst", "README.txt", "README"];
let mut found = None;
for candidate in &candidates {
if let Ok(entry) = tree.get_path(std::path::Path::new(candidate)) {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content()).to_string();
found = Some((candidate.to_string(), content));
break;
}
}
}
match found {
Some((filename, content)) => Ok(serde_json::json!({
"filename": filename,
"content": content,
"size": content.len(),
})),
None => Ok(serde_json::json!({
"filename": null,
"content": null,
"error": "No README file found in repository root",
})),
}
}
/// Tool: repo_commit_log — filtered commit history
async fn repo_commit_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let author = p.get("author").and_then(|v| v.as_str());
let keyword = p.get("keyword").and_then(|v| v.as_str());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let head_oid = head_oid(&domain)?;
// Fetch extra to allow for filtering
let fetch_limit = if author.is_some() || keyword.is_some() {
limit.saturating_mul(5).max(200)
} else {
limit
};
let commits = domain.commit_log(Some(&head_oid), 0, fetch_limit).map_err(|e| e.to_string())?;
let keyword_lower = keyword.map(|k| k.to_lowercase());
let author_lower = author.map(|a| a.to_lowercase());
let result: Vec<serde_json::Value> = commits
.iter()
.filter(|c| {
if let Some(ref al) = author_lower {
if !c.author.name.to_lowercase().contains(al) {
return false;
}
}
if let Some(ref kl) = keyword_lower {
if !c.message.to_lowercase().contains(kl) {
return false;
}
}
true
})
.take(limit)
.map(|c| {
let oid = c.oid.to_string();
serde_json::json!({
"oid": oid,
"short_oid": oid.get(..7).unwrap_or(&oid),
"summary": c.summary,
"author": c.author.name,
"author_email": c.author.email,
"time": c.author.time_secs,
})
})
.collect();
Ok(serde_json::json!({
"total": result.len(),
"commits": result,
}))
}
/// Tool: repo_contributors — contributor statistics
async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let head_oid = head_oid(&domain)?;
// Walk all commits (up to 10000)
let commits = domain.commit_log(Some(&head_oid), 0, 10000).map_err(|e| e.to_string())?;
// Aggregate by author email (more reliable than name)
let mut authors: HashMap<String, serde_json::Value> = HashMap::new();
for c in &commits {
let key = c.author.email.clone();
let entry = authors.entry(key).or_insert_with(|| {
serde_json::json!({
"name": c.author.name,
"email": c.author.email,
"commit_count": 0u64,
"first_commit_time": c.author.time_secs,
"last_commit_time": c.author.time_secs,
})
});
entry["commit_count"] = serde_json::json!(
entry["commit_count"].as_u64().unwrap_or(0) + 1
);
let t = c.author.time_secs;
if t < entry["first_commit_time"].as_i64().unwrap_or(i64::MAX) {
entry["first_commit_time"] = serde_json::json!(t);
}
if t > entry["last_commit_time"].as_i64().unwrap_or(0) {
entry["last_commit_time"] = serde_json::json!(t);
}
}
let mut contributors: Vec<serde_json::Value> = authors.into_values().collect();
contributors.sort_by(|a, b| {
b["commit_count"].as_u64().unwrap_or(0)
.cmp(&a["commit_count"].as_u64().unwrap_or(0))
});
if limit > 0 {
contributors.truncate(limit);
}
Ok(serde_json::json!({
"total_contributors": contributors.len(),
"contributors": contributors,
}))
}
/// Tool: repo_diff_summary — change summary between two revisions
async fn repo_diff_summary_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let from_rev = p.get("from_rev").and_then(|v| v.as_str()).ok_or("missing from_rev")?;
let to_rev = p.get("to_rev").and_then(|v| v.as_str()).unwrap_or("HEAD");
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let from_oid = resolve_commit_oid(&domain, from_rev)?;
let to_oid = resolve_commit_oid(&domain, to_rev)?;
let from_commit = repo.find_commit(from_oid.to_oid().map_err(|e| e.to_string())?)
.map_err(|e| format!("from_rev not found: {e}"))?;
let to_commit = repo.find_commit(to_oid.to_oid().map_err(|e| e.to_string())?)
.map_err(|e| format!("to_rev not found: {e}"))?;
let from_tree = from_commit.tree().map_err(|e| e.to_string())?;
let to_tree = to_commit.tree().map_err(|e| e.to_string())?;
let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
.map_err(|e| e.to_string())?;
let stats = diff.stats().map_err(|e| e.to_string())?;
let files_changed = stats.files_changed();
let insertions = stats.insertions();
let deletions = stats.deletions();
// Collect per-file stats
let mut files: Vec<serde_json::Value> = Vec::new();
for i in 0..diff.deltas().len() {
let delta = diff.deltas().nth(i);
if let Some(d) = delta {
let old_path = d.old_file().path().map(|p| p.to_string_lossy().to_string());
let new_path = d.new_file().path().map(|p| p.to_string_lossy().to_string());
let status = match d.status() {
git2::Delta::Added => "added",
git2::Delta::Deleted => "deleted",
git2::Delta::Modified => "modified",
git2::Delta::Renamed => "renamed",
git2::Delta::Copied => "copied",
_ => "other",
};
files.push(serde_json::json!({
"old_path": old_path,
"new_path": new_path,
"status": status,
}));
}
}
Ok(serde_json::json!({
"from": from_rev,
"to": to_rev,
"files_changed": files_changed,
"insertions": insertions,
"deletions": deletions,
"files": files,
}))
}
// ── Registration ───────────────────────────────────────────────────────────────
macro_rules! param {
($name:expr, $type:expr, $desc:expr, $required:expr) => {
(
$name.into(),
ToolParam {
name: $name.into(),
param_type: $type.into(),
description: Some($desc.into()),
required: $required,
properties: None,
items: None,
},
)
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// repo_search
registry.register(
ToolDefinition::new("repo_search")
.description("Search all files in a repository for a keyword (case-insensitive). Returns matching file paths, hit counts, and context snippets. Skips binary files and generated directories. Use this to find where a function, variable, or concept is defined or used.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("keyword", "string", "Search keyword (case-insensitive)", true),
param!("context_lines", "integer", "Number of context lines around each match (default: 2)", false),
param!("max_results", "integer", "Maximum number of matching files to return (default: 50)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "keyword".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_search_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_readme
registry.register(
ToolDefinition::new("repo_readme")
.description("Read the README file from a repository. Automatically finds README.md, README.markdown, README.rst, README.txt, or README. Returns the full content. Use this as the first step to understand what a project is about.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_readme_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_commit_log
registry.register(
ToolDefinition::new("repo_commit_log")
.description("Get commit history with optional filters. Filter by author name (partial match), keyword in commit message, or limit the number of results. Use this to understand recent activity, find who made specific changes, or trace feature development.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("author", "string", "Filter by author name (partial match, case-insensitive)", false),
param!("keyword", "string", "Filter by keyword in commit message (case-insensitive)", false),
param!("limit", "integer", "Maximum number of commits to return (default: 20)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_commit_log_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_contributors
registry.register(
ToolDefinition::new("repo_contributors")
.description("List repository contributors sorted by commit count. Shows each contributor's name, email, commit count, and first/last commit timestamps. Use this to understand who is involved in a project and their contribution levels.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("limit", "integer", "Maximum number of contributors to return (0 = all, default: 0)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_contributors_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_diff_summary
registry.register(
ToolDefinition::new("repo_diff_summary")
.description("Get a summary of changes between two revisions (commits, branches, or tags). Shows files changed, insertions, deletions, and per-file status (added/modified/deleted/renamed). Use this to understand what changed between versions.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("from_rev", "string", "Source revision (commit SHA, branch name, or tag)", true),
param!("to_rev", "string", "Target revision (default: HEAD)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "from_rev".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_diff_summary_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -132,65 +132,6 @@ 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)] #[derive(Clone)]
pub struct HookMetaDataSync { pub struct HookMetaDataSync {
pub db: AppDatabase, pub db: AppDatabase,
@ -334,9 +275,15 @@ impl HookMetaDataSync {
/// Best-effort — failures are logged but do not fail the sync. /// Best-effort — failures are logged but do not fail the sync.
pub async fn sync_skills(&self) { pub async fn sync_skills(&self) {
let project_uid = self.repo.project; let project_uid = self.repo.project;
let git_repo = self.domain.repo();
let commit_sha = git_repo let repo_root = match self.domain.repo().workdir() {
Some(p) => p.to_path_buf(),
None => return,
};
let commit_sha = self
.domain
.repo()
.head() .head()
.ok() .ok()
.and_then(|h| h.target()) .and_then(|h| h.target())
@ -344,35 +291,19 @@ impl HookMetaDataSync {
.unwrap_or_default(); .unwrap_or_default();
let repo_id = self.repo.id; let repo_id = self.repo.id;
let is_bare = git_repo.is_bare() || git_repo.workdir().is_none(); let discovered = match tokio::task::spawn_blocking(move || {
scan_skills_from_dir(&repo_root, &repo_id, &commit_sha)
let discovered = if is_bare { })
// Bare repo: scan git tree objects directly .await
let git_repo_ref = self.domain.repo(); {
match scan_skills_from_tree(git_repo_ref, &repo_id, &commit_sha) { Ok(Ok(d)) => d,
Ok(d) => d, Ok(Err(e)) => {
Err(e) => { tracing::warn!("failed to scan skills directory error={}", e);
tracing::warn!("failed to scan skills from tree error={}", e); return;
return;
}
} }
} else { Err(e) => {
// Normal repo: walk filesystem tracing::warn!("spawn_blocking join error error={}", e);
let repo_root = git_repo.workdir().unwrap().to_path_buf(); return;
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;
}
} }
}; };

View File

@ -90,7 +90,7 @@ fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
} }
} }
/// Recursively scan `repo_path` for `SKILL.md` files (filesystem walk, non-bare repos). /// Recursively scan `repo_path` for `SKILL.md` files.
/// The skill slug is `{short_repo_id}/{parent_dir_name}` to ensure uniqueness across repos. /// The skill slug is `{short_repo_id}/{parent_dir_name}` to ensure uniqueness across repos.
pub fn scan_repo_for_skills( pub fn scan_repo_for_skills(
repo_path: &Path, repo_path: &Path,
@ -135,72 +135,8 @@ pub fn scan_repo_for_skills(
Ok(discovered) 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. /// Scan a git2::Repository for skills and upsert them into the database.
/// Uses filesystem walk for normal repos, git tree traversal for bare repos. /// Called from the git hook sync path.
pub async fn scan_and_sync_skills( pub async fn scan_and_sync_skills(
db: &db::database::AppDatabase, db: &db::database::AppDatabase,
project_uuid: Uuid, project_uuid: Uuid,
@ -220,21 +156,10 @@ 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 commit_sha = git_repo.head().ok().and_then(|h| h.target()).map(|oid| oid.to_string());
// For bare repos (no workdir), scan git tree objects directly let mut discovered = scan_repo_for_skills(&workdir, repo.id)?;
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 // Fill in commit_sha for discovered skills
for skill in &mut discovered { for skill in &mut discovered {

View File

@ -498,7 +498,7 @@ export function RoomProvider({
), ),
); );
streamingTimersRef.current.delete(msgId); streamingTimersRef.current.delete(msgId);
}, 120000); }, 60000);
streamingTimersRef.current.set(msgId, timer); streamingTimersRef.current.set(msgId, timer);
}, []); }, []);