use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedGitHubUrl {
pub owner: String,
pub repo: String,
}
pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
let trimmed = url.trim();
let patterns = [
r"^https?://github\.com/([^/]+)/([^/\s#?.]+)",
r"^git@github\.com:([^/]+)/([^/\s#?.]+)",
r"^github\.com/([^/]+)/([^/\s#?.]+)",
];
for pattern in &patterns {
if let Ok(re) = Regex::new(pattern) {
if let Some(caps) = re.captures(trimmed) {
let owner = caps.get(1)?.as_str().to_string();
let repo = caps.get(2)?.as_str().trim_end_matches(".git").to_string();
return Some(ParsedGitHubUrl { owner, repo });
}
}
}
if let Ok(re) = Regex::new(r"^([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)$") {
if let Some(caps) = re.captures(trimmed) {
if !trimmed.contains('\\') && !trimmed.contains(':') {
let owner = caps.get(1)?.as_str().to_string();
let repo = caps.get(2)?.as_str().to_string();
return Some(ParsedGitHubUrl { owner, repo });
}
}
}
None
}
pub fn get_clone_base_dir() -> PathBuf {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if cwd.parent().is_none() {
if let Some(home) = dirs::home_dir() {
return home.join(".routa").join("repos");
}
}
cwd.join(".routa").join("repos")
}
pub fn repo_to_dir_name(owner: &str, repo: &str) -> String {
format!("{}--{}", owner, repo)
}
pub fn dir_name_to_repo(dir_name: &str) -> String {
let parts: Vec<&str> = dir_name.splitn(2, "--").collect();
if parts.len() == 2 {
format!("{}/{}", parts[0], parts[1])
} else {
dir_name.to_string()
}
}
pub fn is_git_repository(repo_path: &str) -> bool {
Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(repo_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn get_current_branch(repo_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(repo_path)
.output()
.ok()?;
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
} else {
None
}
}
pub fn list_local_branches(repo_path: &str) -> Vec<String> {
Command::new("git")
.args(["branch", "--format=%(refname:short)"])
.current_dir(repo_path)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
})
.unwrap_or_default()
}
pub fn list_remote_branches(repo_path: &str) -> Vec<String> {
Command::new("git")
.args(["branch", "-r", "--format=%(refname:short)"])
.current_dir(repo_path)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.contains("HEAD"))
.map(|l| l.trim_start_matches("origin/").to_string())
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoBranchInfo {
pub current: String,
pub branches: Vec<String>,
}
pub fn get_branch_info(repo_path: &str) -> RepoBranchInfo {
RepoBranchInfo {
current: get_current_branch(repo_path).unwrap_or_else(|| "unknown".into()),
branches: list_local_branches(repo_path),
}
}
pub fn checkout_branch(repo_path: &str, branch: &str) -> bool {
let ok = Command::new("git")
.args(["checkout", branch])
.current_dir(repo_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
return true;
}
Command::new("git")
.args(["checkout", "-b", branch])
.current_dir(repo_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn fetch_remote(repo_path: &str) -> bool {
Command::new("git")
.args(["fetch", "--all", "--prune"])
.current_dir(repo_path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn pull_branch(repo_path: &str) -> Result<(), String> {
let output = Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(repo_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchStatus {
pub ahead: i32,
pub behind: i32,
pub has_uncommitted_changes: bool,
}
pub fn get_branch_status(repo_path: &str, branch: &str) -> BranchStatus {
let mut result = BranchStatus {
ahead: 0,
behind: 0,
has_uncommitted_changes: false,
};
if let Ok(o) = Command::new("git")
.args([
"rev-list",
"--left-right",
"--count",
&format!("{}...origin/{}", branch, branch),
])
.current_dir(repo_path)
.output()
{
if o.status.success() {
let text = String::from_utf8_lossy(&o.stdout);
let parts: Vec<&str> = text.split_whitespace().collect();
if parts.len() == 2 {
result.ahead = parts[0].parse().unwrap_or(0);
result.behind = parts[1].parse().unwrap_or(0);
}
}
}
if let Ok(o) = Command::new("git")
.args(["status", "--porcelain", "-uall"])
.current_dir(repo_path)
.output()
{
if o.status.success() {
result.has_uncommitted_changes = !String::from_utf8_lossy(&o.stdout).trim().is_empty();
}
}
result
}
pub fn reset_local_changes(repo_path: &str) -> Result<(), String> {
let reset_output = Command::new("git")
.args(["reset", "--hard", "HEAD"])
.current_dir(repo_path)
.output()
.map_err(|e| e.to_string())?;
if !reset_output.status.success() {
return Err(String::from_utf8_lossy(&reset_output.stderr)
.trim()
.to_string());
}
let clean_output = Command::new("git")
.args(["clean", "-fd"])
.current_dir(repo_path)
.output()
.map_err(|e| e.to_string())?;
if !clean_output.status.success() {
return Err(String::from_utf8_lossy(&clean_output.stderr)
.trim()
.to_string());
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoStatus {
pub clean: bool,
pub ahead: i32,
pub behind: i32,
pub modified: i32,
pub untracked: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum FileChangeStatus {
Modified,
Added,
Deleted,
Renamed,
Copied,
Untracked,
Typechange,
Conflicted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GitFileChange {
pub path: String,
pub status: FileChangeStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoChanges {
pub branch: String,
pub status: RepoStatus,
pub files: Vec<GitFileChange>,
}
pub fn get_repo_status(repo_path: &str) -> RepoStatus {
let mut status = RepoStatus {
clean: true,
ahead: 0,
behind: 0,
modified: 0,
untracked: 0,
};
if let Ok(o) = Command::new("git")
.args(["status", "--porcelain", "-uall"])
.current_dir(repo_path)
.output()
{
if o.status.success() {
let text = String::from_utf8_lossy(&o.stdout);
let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
status.modified = lines.iter().filter(|l| !l.starts_with("??")).count() as i32;
status.untracked = lines.iter().filter(|l| l.starts_with("??")).count() as i32;
status.clean = lines.is_empty();
}
}
if let Ok(o) = Command::new("git")
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(repo_path)
.output()
{
if o.status.success() {
let text = String::from_utf8_lossy(&o.stdout);
let parts: Vec<&str> = text.split_whitespace().collect();
if parts.len() == 2 {
status.ahead = parts[0].parse().unwrap_or(0);
status.behind = parts[1].parse().unwrap_or(0);
}
}
}
status
}
fn map_porcelain_status(code: &str) -> FileChangeStatus {
if code == "??" {
return FileChangeStatus::Untracked;
}
let mut chars = code.chars();
let index_status = chars.next().unwrap_or(' ');
let worktree_status = chars.next().unwrap_or(' ');
if index_status == 'U' || worktree_status == 'U' || code == "AA" || code == "DD" {
return FileChangeStatus::Conflicted;
}
if index_status == 'R' || worktree_status == 'R' {
return FileChangeStatus::Renamed;
}
if index_status == 'C' || worktree_status == 'C' {
return FileChangeStatus::Copied;
}
if index_status == 'A' || worktree_status == 'A' {
return FileChangeStatus::Added;
}
if index_status == 'D' || worktree_status == 'D' {
return FileChangeStatus::Deleted;
}
if index_status == 'T' || worktree_status == 'T' {
return FileChangeStatus::Typechange;
}
FileChangeStatus::Modified
}
pub fn parse_git_status_porcelain(output: &str) -> Vec<GitFileChange> {
output
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
if line.len() < 3 {
return None;
}
let code = &line[0..2];
if code == "!!" {
return None;
}
let raw_path = line[3..].trim().to_string();
let status = map_porcelain_status(code);
if matches!(status, FileChangeStatus::Renamed | FileChangeStatus::Copied)
&& raw_path.contains(" -> ")
{
let parts: Vec<&str> = raw_path.splitn(2, " -> ").collect();
if parts.len() == 2 {
return Some(GitFileChange {
path: parts[1].to_string(),
previous_path: Some(parts[0].to_string()),
status,
});
}
}
Some(GitFileChange {
path: raw_path,
previous_path: None,
status,
})
})
.collect()
}
pub fn get_repo_changes(repo_path: &str) -> RepoChanges {
let branch = get_current_branch(repo_path).unwrap_or_else(|| "unknown".into());
let status = get_repo_status(repo_path);
let files = Command::new("git")
.args(["status", "--porcelain", "-uall"])
.current_dir(repo_path)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| parse_git_status_porcelain(&String::from_utf8_lossy(&o.stdout)))
.unwrap_or_default();
RepoChanges {
branch,
status,
files,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClonedRepoInfo {
pub name: String,
pub path: String,
pub dir_name: String,
pub branch: String,
pub branches: Vec<String>,
pub status: RepoStatus,
}
pub fn list_cloned_repos() -> Vec<ClonedRepoInfo> {
let base_dir = get_clone_base_dir();
if !base_dir.exists() {
return vec![];
}
let entries = match std::fs::read_dir(&base_dir) {
Ok(e) => e,
Err(_) => return vec![],
};
entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| {
let full_path = e.path();
let dir_name = e.file_name().to_string_lossy().to_string();
let path_str = full_path.to_string_lossy().to_string();
let branch_info = get_branch_info(&path_str);
let repo_status = get_repo_status(&path_str);
ClonedRepoInfo {
name: dir_name_to_repo(&dir_name),
path: path_str,
dir_name,
branch: branch_info.current,
branches: branch_info.branches,
status: repo_status,
}
})
.collect()
}
pub fn discover_skills_from_path(repo_path: &Path) -> Vec<DiscoveredSkill> {
let dirs_to_check = [
"skills",
".agents/skills",
".opencode/skills",
".claude/skills",
];
let mut result = Vec::new();
for dir in &dirs_to_check {
let skill_dir = repo_path.join(dir);
if skill_dir.is_dir() {
scan_skill_dir(&skill_dir, &mut result);
}
}
let root_skill = repo_path.join("SKILL.md");
if root_skill.is_file() {
if let Some(skill) = parse_discovered_skill(&root_skill) {
result.push(skill);
}
}
result
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredSkill {
pub name: String,
pub description: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compatibility: Option<String>,
}
fn scan_skill_dir(dir: &Path, out: &mut Vec<DiscoveredSkill>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.is_file() {
if let Some(skill) = parse_discovered_skill(&skill_file) {
out.push(skill);
}
}
}
}
}
#[derive(Debug, serde::Deserialize)]
struct SkillFrontmatter {
name: String,
description: String,
#[serde(default)]
license: Option<String>,
#[serde(default)]
compatibility: Option<String>,
}
fn parse_discovered_skill(path: &Path) -> Option<DiscoveredSkill> {
let content = std::fs::read_to_string(path).ok()?;
if let Some((fm_str, _body)) = extract_frontmatter_str(&content) {
if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&fm_str) {
return Some(DiscoveredSkill {
name: fm.name,
description: fm.description,
source: path.to_string_lossy().to_string(),
license: fm.license,
compatibility: fm.compatibility,
});
}
}
let name = path
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".into());
let description = content
.lines()
.skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
.take_while(|l| !l.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
Some(DiscoveredSkill {
name,
description: if description.is_empty() {
"No description".into()
} else {
description
},
source: path.to_string_lossy().to_string(),
license: None,
compatibility: None,
})
}
#[cfg(test)]
mod status_tests {
use super::{parse_git_status_porcelain, FileChangeStatus};
#[test]
fn parse_git_status_porcelain_maps_statuses() {
let output = " M src/app.ts\nA src/new.ts\nD src/old.ts\nR src/was.ts -> src/now.ts\n?? scratch.txt\nUU merge.txt\n";
let files = parse_git_status_porcelain(output);
assert_eq!(files.len(), 6);
assert_eq!(files[0].status, FileChangeStatus::Modified);
assert_eq!(files[1].status, FileChangeStatus::Added);
assert_eq!(files[2].status, FileChangeStatus::Deleted);
assert_eq!(files[3].status, FileChangeStatus::Renamed);
assert_eq!(files[3].previous_path.as_deref(), Some("src/was.ts"));
assert_eq!(files[3].path, "src/now.ts");
assert_eq!(files[4].status, FileChangeStatus::Untracked);
assert_eq!(files[5].status, FileChangeStatus::Conflicted);
}
}
fn extract_frontmatter_str(contents: &str) -> Option<(String, String)> {
let mut lines = contents.lines();
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
return None;
}
let mut frontmatter_lines: Vec<&str> = Vec::new();
let mut body_start = false;
let mut body_lines: Vec<&str> = Vec::new();
for line in lines {
if !body_start {
if line.trim() == "---" {
body_start = true;
} else {
frontmatter_lines.push(line);
}
} else {
body_lines.push(line);
}
}
if frontmatter_lines.is_empty() || !body_start {
return None;
}
Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
}
pub fn get_worktree_base_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".routa")
.join("worktrees")
}
pub fn get_default_workspace_worktree_root(workspace_id: &str) -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".routa")
.join("workspace")
.join(workspace_id)
}
pub fn branch_to_safe_dir_name(branch: &str) -> String {
branch
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect()
}
pub fn worktree_prune(repo_path: &str) -> Result<(), String> {
let output = Command::new("git")
.args(["worktree", "prune"])
.current_dir(repo_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
pub fn worktree_add(
repo_path: &str,
worktree_path: &str,
branch: &str,
base_branch: &str,
create_branch: bool,
) -> Result<(), String> {
if let Some(parent) = Path::new(worktree_path).parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let args = if create_branch {
vec![
"worktree".to_string(),
"add".to_string(),
"-b".to_string(),
branch.to_string(),
worktree_path.to_string(),
base_branch.to_string(),
]
} else {
vec![
"worktree".to_string(),
"add".to_string(),
worktree_path.to_string(),
branch.to_string(),
]
};
let output = Command::new("git")
.args(&args)
.current_dir(repo_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
pub fn worktree_remove(repo_path: &str, worktree_path: &str, force: bool) -> Result<(), String> {
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(worktree_path);
let output = Command::new("git")
.args(&args)
.current_dir(repo_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeListEntry {
pub path: String,
pub head: String,
pub branch: String,
}
pub fn worktree_list(repo_path: &str) -> Vec<WorktreeListEntry> {
let output = match Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(repo_path)
.output()
{
Ok(o) if o.status.success() => o,
_ => return vec![],
};
let text = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
let mut current_path = String::new();
let mut current_head = String::new();
let mut current_branch = String::new();
for line in text.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
if !current_path.is_empty() {
entries.push(WorktreeListEntry {
path: std::mem::take(&mut current_path),
head: std::mem::take(&mut current_head),
branch: std::mem::take(&mut current_branch),
});
}
current_path = p.to_string();
} else if let Some(h) = line.strip_prefix("HEAD ") {
current_head = h.to_string();
} else if let Some(b) = line.strip_prefix("branch ") {
current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
}
}
if !current_path.is_empty() {
entries.push(WorktreeListEntry {
path: current_path,
head: current_head,
branch: current_branch,
});
}
entries
}
pub fn branch_exists(repo_path: &str, branch: &str) -> bool {
Command::new("git")
.args(["branch", "--list", branch])
.current_dir(repo_path)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
.unwrap_or(false)
}
pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == ".git" || name_str == "node_modules" {
continue;
}
copy_dir_recursive(&src_path, &dest_path)?;
} else {
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn parse_github_url_supports_multiple_formats() {
let https = parse_github_url("https://github.com/phodal/routa-js.git").unwrap();
assert_eq!(https.owner, "phodal");
assert_eq!(https.repo, "routa-js");
let ssh = parse_github_url("git@github.com:owner/repo-name.git").unwrap();
assert_eq!(ssh.owner, "owner");
assert_eq!(ssh.repo, "repo-name");
let shorthand = parse_github_url("foo/bar.baz").unwrap();
assert_eq!(shorthand.owner, "foo");
assert_eq!(shorthand.repo, "bar.baz");
assert!(parse_github_url(r"C:\tmp\repo").is_none());
}
#[test]
fn repo_dir_name_conversions_are_stable() {
let dir = repo_to_dir_name("org", "project");
assert_eq!(dir, "org--project");
assert_eq!(dir_name_to_repo(&dir), "org/project");
assert_eq!(dir_name_to_repo("no-separator"), "no-separator");
}
#[test]
fn frontmatter_extraction_requires_both_delimiters() {
let content = "---\nname: demo\ndescription: hello\n---\nbody";
let (fm, body) = extract_frontmatter_str(content).unwrap();
assert!(fm.contains("name: demo"));
assert_eq!(body, "body");
assert!(extract_frontmatter_str("name: x\n---\nbody").is_none());
assert!(extract_frontmatter_str("---\nname: x\nbody").is_none());
}
#[test]
fn parse_discovered_skill_supports_frontmatter_and_fallback() {
let temp = tempdir().unwrap();
let skill_dir = temp.path().join("skills").join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let fm_skill = skill_dir.join("SKILL.md");
fs::write(
&fm_skill,
"---\nname: Demo Skill\ndescription: Does demo things\nlicense: MIT\ncompatibility: rust\n---\n# Body\n",
)
.unwrap();
let parsed = parse_discovered_skill(&fm_skill).unwrap();
assert_eq!(parsed.name, "Demo Skill");
assert_eq!(parsed.description, "Does demo things");
assert_eq!(parsed.license.as_deref(), Some("MIT"));
assert_eq!(parsed.compatibility.as_deref(), Some("rust"));
let fallback_dir = temp.path().join("skills").join("fallback-skill");
fs::create_dir_all(&fallback_dir).unwrap();
let fallback_file = fallback_dir.join("SKILL.md");
fs::write(
&fallback_file,
"# Title\n\nFirst line of fallback description.\nSecond line.\n\n## Next section\n",
)
.unwrap();
let fallback = parse_discovered_skill(&fallback_file).unwrap();
assert_eq!(fallback.name, "fallback-skill");
assert_eq!(
fallback.description,
"First line of fallback description. Second line."
);
assert!(fallback.license.is_none());
assert!(fallback.compatibility.is_none());
}
#[test]
fn discover_skills_from_path_scans_known_locations_and_root() {
let temp = tempdir().unwrap();
let skill_paths = [
temp.path().join("skills").join("a").join("SKILL.md"),
temp.path()
.join(".agents/skills")
.join("b")
.join("SKILL.md"),
temp.path()
.join(".opencode/skills")
.join("c")
.join("SKILL.md"),
temp.path()
.join(".claude/skills")
.join("d")
.join("SKILL.md"),
temp.path().join("SKILL.md"),
];
for path in &skill_paths {
fs::create_dir_all(path.parent().unwrap()).unwrap();
}
fs::write(
&skill_paths[0],
"---\nname: skill-a\ndescription: from skills\n---\n",
)
.unwrap();
fs::write(
&skill_paths[1],
"---\nname: skill-b\ndescription: from agents\n---\n",
)
.unwrap();
fs::write(
&skill_paths[2],
"---\nname: skill-c\ndescription: from opencode\n---\n",
)
.unwrap();
fs::write(
&skill_paths[3],
"---\nname: skill-d\ndescription: from claude\n---\n",
)
.unwrap();
fs::write(
&skill_paths[4],
"---\nname: root-skill\ndescription: from root\n---\n",
)
.unwrap();
let discovered = discover_skills_from_path(temp.path());
let mut names = discovered.into_iter().map(|s| s.name).collect::<Vec<_>>();
names.sort();
assert_eq!(
names,
vec![
"root-skill".to_string(),
"skill-a".to_string(),
"skill-b".to_string(),
"skill-c".to_string(),
"skill-d".to_string()
]
);
}
#[test]
fn branch_to_safe_dir_name_replaces_unsafe_chars() {
assert_eq!(
branch_to_safe_dir_name("feature/new ui@2026"),
"feature-new-ui-2026"
);
assert_eq!(branch_to_safe_dir_name("release-1.2.3"), "release-1.2.3");
}
#[test]
fn copy_dir_recursive_skips_git_and_node_modules() {
let temp = tempdir().unwrap();
let src = temp.path().join("src");
let dest = temp.path().join("dest");
fs::create_dir_all(src.join(".git")).unwrap();
fs::create_dir_all(src.join("node_modules/pkg")).unwrap();
fs::create_dir_all(src.join("nested")).unwrap();
fs::write(src.join(".git/config"), "ignored").unwrap();
fs::write(src.join("node_modules/pkg/index.js"), "ignored").unwrap();
fs::write(src.join("nested/kept.txt"), "hello").unwrap();
fs::write(src.join("root.txt"), "root").unwrap();
copy_dir_recursive(&src, &dest).unwrap();
assert!(dest.join("root.txt").is_file());
assert!(dest.join("nested/kept.txt").is_file());
assert!(!dest.join(".git").exists());
assert!(!dest.join("node_modules").exists());
}
}