1use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ParsedGitHubUrl {
11 pub owner: String,
12 pub repo: String,
13}
14
15pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
17 let trimmed = url.trim();
18
19 let patterns = [
20 r"^https?://github\.com/([^/]+)/([^/\s#?.]+)",
21 r"^git@github\.com:([^/]+)/([^/\s#?.]+)",
22 r"^github\.com/([^/]+)/([^/\s#?.]+)",
23 ];
24
25 for pattern in &patterns {
26 if let Ok(re) = Regex::new(pattern) {
27 if let Some(caps) = re.captures(trimmed) {
28 let owner = caps.get(1)?.as_str().to_string();
29 let repo = caps.get(2)?.as_str().trim_end_matches(".git").to_string();
30 return Some(ParsedGitHubUrl { owner, repo });
31 }
32 }
33 }
34
35 if let Ok(re) = Regex::new(r"^([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)$") {
36 if let Some(caps) = re.captures(trimmed) {
37 if !trimmed.contains('\\') && !trimmed.contains(':') {
38 let owner = caps.get(1)?.as_str().to_string();
39 let repo = caps.get(2)?.as_str().to_string();
40 return Some(ParsedGitHubUrl { owner, repo });
41 }
42 }
43 }
44
45 None
46}
47
48pub fn get_clone_base_dir() -> PathBuf {
50 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
51 if cwd.parent().is_none() {
52 if let Some(home) = dirs::home_dir() {
53 return home.join(".routa").join("repos");
54 }
55 }
56 cwd.join(".routa").join("repos")
57}
58
59pub fn repo_to_dir_name(owner: &str, repo: &str) -> String {
60 format!("{}--{}", owner, repo)
61}
62
63pub fn dir_name_to_repo(dir_name: &str) -> String {
64 let parts: Vec<&str> = dir_name.splitn(2, "--").collect();
65 if parts.len() == 2 {
66 format!("{}/{}", parts[0], parts[1])
67 } else {
68 dir_name.to_string()
69 }
70}
71
72pub fn get_current_branch(repo_path: &str) -> Option<String> {
73 let output = Command::new("git")
74 .args(["rev-parse", "--abbrev-ref", "HEAD"])
75 .current_dir(repo_path)
76 .output()
77 .ok()?;
78 if output.status.success() {
79 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
80 if s.is_empty() {
81 None
82 } else {
83 Some(s)
84 }
85 } else {
86 None
87 }
88}
89
90pub fn list_local_branches(repo_path: &str) -> Vec<String> {
91 Command::new("git")
92 .args(["branch", "--format=%(refname:short)"])
93 .current_dir(repo_path)
94 .output()
95 .ok()
96 .filter(|o| o.status.success())
97 .map(|o| {
98 String::from_utf8_lossy(&o.stdout)
99 .lines()
100 .map(|l| l.trim().to_string())
101 .filter(|l| !l.is_empty())
102 .collect()
103 })
104 .unwrap_or_default()
105}
106
107pub fn list_remote_branches(repo_path: &str) -> Vec<String> {
108 Command::new("git")
109 .args(["branch", "-r", "--format=%(refname:short)"])
110 .current_dir(repo_path)
111 .output()
112 .ok()
113 .filter(|o| o.status.success())
114 .map(|o| {
115 String::from_utf8_lossy(&o.stdout)
116 .lines()
117 .map(|l| l.trim().to_string())
118 .filter(|l| !l.is_empty() && !l.contains("HEAD"))
119 .map(|l| l.trim_start_matches("origin/").to_string())
120 .collect()
121 })
122 .unwrap_or_default()
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct RepoBranchInfo {
127 pub current: String,
128 pub branches: Vec<String>,
129}
130
131pub fn get_branch_info(repo_path: &str) -> RepoBranchInfo {
132 RepoBranchInfo {
133 current: get_current_branch(repo_path).unwrap_or_else(|| "unknown".into()),
134 branches: list_local_branches(repo_path),
135 }
136}
137
138pub fn checkout_branch(repo_path: &str, branch: &str) -> bool {
139 let ok = Command::new("git")
140 .args(["checkout", branch])
141 .current_dir(repo_path)
142 .output()
143 .map(|o| o.status.success())
144 .unwrap_or(false);
145 if ok {
146 return true;
147 }
148 Command::new("git")
149 .args(["checkout", "-b", branch])
150 .current_dir(repo_path)
151 .output()
152 .map(|o| o.status.success())
153 .unwrap_or(false)
154}
155
156pub fn fetch_remote(repo_path: &str) -> bool {
157 Command::new("git")
158 .args(["fetch", "--all", "--prune"])
159 .current_dir(repo_path)
160 .output()
161 .map(|o| o.status.success())
162 .unwrap_or(false)
163}
164
165pub fn pull_branch(repo_path: &str) -> Result<(), String> {
166 let output = Command::new("git")
167 .args(["pull", "--ff-only"])
168 .current_dir(repo_path)
169 .output()
170 .map_err(|e| e.to_string())?;
171 if output.status.success() {
172 Ok(())
173 } else {
174 Err(String::from_utf8_lossy(&output.stderr).to_string())
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct BranchStatus {
181 pub ahead: i32,
182 pub behind: i32,
183 pub has_uncommitted_changes: bool,
184}
185
186pub fn get_branch_status(repo_path: &str, branch: &str) -> BranchStatus {
187 let mut result = BranchStatus {
188 ahead: 0,
189 behind: 0,
190 has_uncommitted_changes: false,
191 };
192
193 if let Ok(o) = Command::new("git")
194 .args([
195 "rev-list",
196 "--left-right",
197 "--count",
198 &format!("{}...origin/{}", branch, branch),
199 ])
200 .current_dir(repo_path)
201 .output()
202 {
203 if o.status.success() {
204 let text = String::from_utf8_lossy(&o.stdout);
205 let parts: Vec<&str> = text.split_whitespace().collect();
206 if parts.len() == 2 {
207 result.ahead = parts[0].parse().unwrap_or(0);
208 result.behind = parts[1].parse().unwrap_or(0);
209 }
210 }
211 }
212
213 if let Ok(o) = Command::new("git")
214 .args(["status", "--porcelain"])
215 .current_dir(repo_path)
216 .output()
217 {
218 if o.status.success() {
219 result.has_uncommitted_changes = !String::from_utf8_lossy(&o.stdout).trim().is_empty();
220 }
221 }
222
223 result
224}
225
226pub fn reset_local_changes(repo_path: &str) -> Result<(), String> {
227 let reset_output = Command::new("git")
228 .args(["reset", "--hard", "HEAD"])
229 .current_dir(repo_path)
230 .output()
231 .map_err(|e| e.to_string())?;
232 if !reset_output.status.success() {
233 return Err(String::from_utf8_lossy(&reset_output.stderr)
234 .trim()
235 .to_string());
236 }
237
238 let clean_output = Command::new("git")
239 .args(["clean", "-fd"])
240 .current_dir(repo_path)
241 .output()
242 .map_err(|e| e.to_string())?;
243 if !clean_output.status.success() {
244 return Err(String::from_utf8_lossy(&clean_output.stderr)
245 .trim()
246 .to_string());
247 }
248
249 Ok(())
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct RepoStatus {
255 pub clean: bool,
256 pub ahead: i32,
257 pub behind: i32,
258 pub modified: i32,
259 pub untracked: i32,
260}
261
262pub fn get_repo_status(repo_path: &str) -> RepoStatus {
263 let mut status = RepoStatus {
264 clean: true,
265 ahead: 0,
266 behind: 0,
267 modified: 0,
268 untracked: 0,
269 };
270
271 if let Ok(o) = Command::new("git")
272 .args(["status", "--porcelain"])
273 .current_dir(repo_path)
274 .output()
275 {
276 if o.status.success() {
277 let text = String::from_utf8_lossy(&o.stdout);
278 let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
279 status.modified = lines.iter().filter(|l| !l.starts_with("??")).count() as i32;
280 status.untracked = lines.iter().filter(|l| l.starts_with("??")).count() as i32;
281 status.clean = lines.is_empty();
282 }
283 }
284
285 if let Ok(o) = Command::new("git")
286 .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
287 .current_dir(repo_path)
288 .output()
289 {
290 if o.status.success() {
291 let text = String::from_utf8_lossy(&o.stdout);
292 let parts: Vec<&str> = text.split_whitespace().collect();
293 if parts.len() == 2 {
294 status.ahead = parts[0].parse().unwrap_or(0);
295 status.behind = parts[1].parse().unwrap_or(0);
296 }
297 }
298 }
299
300 status
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct ClonedRepoInfo {
306 pub name: String,
307 pub path: String,
308 pub dir_name: String,
309 pub branch: String,
310 pub branches: Vec<String>,
311 pub status: RepoStatus,
312}
313
314pub fn list_cloned_repos() -> Vec<ClonedRepoInfo> {
316 let base_dir = get_clone_base_dir();
317 if !base_dir.exists() {
318 return vec![];
319 }
320
321 let entries = match std::fs::read_dir(&base_dir) {
322 Ok(e) => e,
323 Err(_) => return vec![],
324 };
325
326 entries
327 .flatten()
328 .filter(|e| e.path().is_dir())
329 .map(|e| {
330 let full_path = e.path();
331 let dir_name = e.file_name().to_string_lossy().to_string();
332 let path_str = full_path.to_string_lossy().to_string();
333 let branch_info = get_branch_info(&path_str);
334 let repo_status = get_repo_status(&path_str);
335 ClonedRepoInfo {
336 name: dir_name_to_repo(&dir_name),
337 path: path_str,
338 dir_name,
339 branch: branch_info.current,
340 branches: branch_info.branches,
341 status: repo_status,
342 }
343 })
344 .collect()
345}
346
347pub fn discover_skills_from_path(repo_path: &Path) -> Vec<DiscoveredSkill> {
349 let dirs_to_check = [
350 "skills",
351 ".agents/skills",
352 ".opencode/skills",
353 ".claude/skills",
354 ];
355
356 let mut result = Vec::new();
357
358 for dir in &dirs_to_check {
359 let skill_dir = repo_path.join(dir);
360 if skill_dir.is_dir() {
361 scan_skill_dir(&skill_dir, &mut result);
362 }
363 }
364
365 let root_skill = repo_path.join("SKILL.md");
367 if root_skill.is_file() {
368 if let Some(skill) = parse_discovered_skill(&root_skill) {
369 result.push(skill);
370 }
371 }
372
373 result
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
377#[serde(rename_all = "camelCase")]
378pub struct DiscoveredSkill {
379 pub name: String,
380 pub description: String,
381 pub source: String,
382 #[serde(skip_serializing_if = "Option::is_none")]
383 pub license: Option<String>,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub compatibility: Option<String>,
386}
387
388fn scan_skill_dir(dir: &Path, out: &mut Vec<DiscoveredSkill>) {
389 let entries = match std::fs::read_dir(dir) {
390 Ok(e) => e,
391 Err(_) => return,
392 };
393
394 for entry in entries.flatten() {
395 let path = entry.path();
396 if path.is_dir() {
397 let skill_file = path.join("SKILL.md");
398 if skill_file.is_file() {
399 if let Some(skill) = parse_discovered_skill(&skill_file) {
400 out.push(skill);
401 }
402 }
403 }
404 }
405}
406
407#[derive(Debug, serde::Deserialize)]
409struct SkillFrontmatter {
410 name: String,
411 description: String,
412 #[serde(default)]
413 license: Option<String>,
414 #[serde(default)]
415 compatibility: Option<String>,
416}
417
418fn parse_discovered_skill(path: &Path) -> Option<DiscoveredSkill> {
419 let content = std::fs::read_to_string(path).ok()?;
420
421 if let Some((fm_str, _body)) = extract_frontmatter_str(&content) {
423 if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&fm_str) {
424 return Some(DiscoveredSkill {
425 name: fm.name,
426 description: fm.description,
427 source: path.to_string_lossy().to_string(),
428 license: fm.license,
429 compatibility: fm.compatibility,
430 });
431 }
432 }
433
434 let name = path
436 .parent()
437 .and_then(|p| p.file_name())
438 .map(|n| n.to_string_lossy().to_string())
439 .unwrap_or_else(|| "unknown".into());
440
441 let description = content
442 .lines()
443 .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
444 .take_while(|l| !l.trim().is_empty())
445 .collect::<Vec<_>>()
446 .join(" ");
447
448 Some(DiscoveredSkill {
449 name,
450 description: if description.is_empty() {
451 "No description".into()
452 } else {
453 description
454 },
455 source: path.to_string_lossy().to_string(),
456 license: None,
457 compatibility: None,
458 })
459}
460
461fn extract_frontmatter_str(contents: &str) -> Option<(String, String)> {
463 let mut lines = contents.lines();
464 if !matches!(lines.next(), Some(line) if line.trim() == "---") {
465 return None;
466 }
467
468 let mut frontmatter_lines: Vec<&str> = Vec::new();
469 let mut body_start = false;
470 let mut body_lines: Vec<&str> = Vec::new();
471
472 for line in lines {
473 if !body_start {
474 if line.trim() == "---" {
475 body_start = true;
476 } else {
477 frontmatter_lines.push(line);
478 }
479 } else {
480 body_lines.push(line);
481 }
482 }
483
484 if frontmatter_lines.is_empty() || !body_start {
485 return None;
486 }
487
488 Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
489}
490
491pub fn get_worktree_base_dir() -> PathBuf {
495 dirs::home_dir()
496 .unwrap_or_else(|| PathBuf::from("."))
497 .join(".routa")
498 .join("worktrees")
499}
500
501pub fn get_default_workspace_worktree_root(workspace_id: &str) -> PathBuf {
503 dirs::home_dir()
504 .unwrap_or_else(|| PathBuf::from("."))
505 .join(".routa")
506 .join("workspace")
507 .join(workspace_id)
508}
509
510pub fn branch_to_safe_dir_name(branch: &str) -> String {
512 branch
513 .chars()
514 .map(|c| {
515 if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' {
516 c
517 } else {
518 '-'
519 }
520 })
521 .collect()
522}
523
524pub fn worktree_prune(repo_path: &str) -> Result<(), String> {
526 let output = Command::new("git")
527 .args(["worktree", "prune"])
528 .current_dir(repo_path)
529 .output()
530 .map_err(|e| e.to_string())?;
531 if output.status.success() {
532 Ok(())
533 } else {
534 Err(String::from_utf8_lossy(&output.stderr).to_string())
535 }
536}
537
538pub fn worktree_add(
540 repo_path: &str,
541 worktree_path: &str,
542 branch: &str,
543 base_branch: &str,
544 create_branch: bool,
545) -> Result<(), String> {
546 if let Some(parent) = Path::new(worktree_path).parent() {
548 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
549 }
550
551 let args = if create_branch {
552 vec![
553 "worktree".to_string(),
554 "add".to_string(),
555 "-b".to_string(),
556 branch.to_string(),
557 worktree_path.to_string(),
558 base_branch.to_string(),
559 ]
560 } else {
561 vec![
562 "worktree".to_string(),
563 "add".to_string(),
564 worktree_path.to_string(),
565 branch.to_string(),
566 ]
567 };
568
569 let output = Command::new("git")
570 .args(&args)
571 .current_dir(repo_path)
572 .output()
573 .map_err(|e| e.to_string())?;
574
575 if output.status.success() {
576 Ok(())
577 } else {
578 Err(String::from_utf8_lossy(&output.stderr).to_string())
579 }
580}
581
582pub fn worktree_remove(repo_path: &str, worktree_path: &str, force: bool) -> Result<(), String> {
584 let mut args = vec!["worktree", "remove"];
585 if force {
586 args.push("--force");
587 }
588 args.push(worktree_path);
589
590 let output = Command::new("git")
591 .args(&args)
592 .current_dir(repo_path)
593 .output()
594 .map_err(|e| e.to_string())?;
595
596 if output.status.success() {
597 Ok(())
598 } else {
599 Err(String::from_utf8_lossy(&output.stderr).to_string())
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct WorktreeListEntry {
606 pub path: String,
607 pub head: String,
608 pub branch: String,
609}
610
611pub fn worktree_list(repo_path: &str) -> Vec<WorktreeListEntry> {
613 let output = match Command::new("git")
614 .args(["worktree", "list", "--porcelain"])
615 .current_dir(repo_path)
616 .output()
617 {
618 Ok(o) if o.status.success() => o,
619 _ => return vec![],
620 };
621
622 let text = String::from_utf8_lossy(&output.stdout);
623 let mut entries = Vec::new();
624 let mut current_path = String::new();
625 let mut current_head = String::new();
626 let mut current_branch = String::new();
627
628 for line in text.lines() {
629 if let Some(p) = line.strip_prefix("worktree ") {
630 if !current_path.is_empty() {
631 entries.push(WorktreeListEntry {
632 path: std::mem::take(&mut current_path),
633 head: std::mem::take(&mut current_head),
634 branch: std::mem::take(&mut current_branch),
635 });
636 }
637 current_path = p.to_string();
638 } else if let Some(h) = line.strip_prefix("HEAD ") {
639 current_head = h.to_string();
640 } else if let Some(b) = line.strip_prefix("branch ") {
641 current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
643 }
644 }
645
646 if !current_path.is_empty() {
648 entries.push(WorktreeListEntry {
649 path: current_path,
650 head: current_head,
651 branch: current_branch,
652 });
653 }
654
655 entries
656}
657
658pub fn branch_exists(repo_path: &str, branch: &str) -> bool {
660 Command::new("git")
661 .args(["branch", "--list", branch])
662 .current_dir(repo_path)
663 .output()
664 .ok()
665 .filter(|o| o.status.success())
666 .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
667 .unwrap_or(false)
668}
669
670pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
672 std::fs::create_dir_all(dest)?;
673 for entry in std::fs::read_dir(src)? {
676 let entry = entry?;
677 let src_path = entry.path();
678 let dest_path = dest.join(entry.file_name());
679
680 if src_path.is_dir() {
681 let name = entry.file_name();
682 let name_str = name.to_string_lossy();
683 if name_str == ".git" || name_str == "node_modules" {
684 continue;
685 }
686 copy_dir_recursive(&src_path, &dest_path)?;
687 } else {
688 std::fs::copy(&src_path, &dest_path)?;
689 }
690 }
691 Ok(())
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697 use std::fs;
698 use tempfile::tempdir;
699
700 #[test]
701 fn parse_github_url_supports_multiple_formats() {
702 let https = parse_github_url("https://github.com/phodal/routa-js.git").unwrap();
703 assert_eq!(https.owner, "phodal");
704 assert_eq!(https.repo, "routa-js");
705
706 let ssh = parse_github_url("git@github.com:owner/repo-name.git").unwrap();
707 assert_eq!(ssh.owner, "owner");
708 assert_eq!(ssh.repo, "repo-name");
709
710 let shorthand = parse_github_url("foo/bar.baz").unwrap();
711 assert_eq!(shorthand.owner, "foo");
712 assert_eq!(shorthand.repo, "bar.baz");
713
714 assert!(parse_github_url(r"C:\tmp\repo").is_none());
715 }
716
717 #[test]
718 fn repo_dir_name_conversions_are_stable() {
719 let dir = repo_to_dir_name("org", "project");
720 assert_eq!(dir, "org--project");
721 assert_eq!(dir_name_to_repo(&dir), "org/project");
722 assert_eq!(dir_name_to_repo("no-separator"), "no-separator");
723 }
724
725 #[test]
726 fn frontmatter_extraction_requires_both_delimiters() {
727 let content = "---\nname: demo\ndescription: hello\n---\nbody";
728 let (fm, body) = extract_frontmatter_str(content).unwrap();
729 assert!(fm.contains("name: demo"));
730 assert_eq!(body, "body");
731
732 assert!(extract_frontmatter_str("name: x\n---\nbody").is_none());
733 assert!(extract_frontmatter_str("---\nname: x\nbody").is_none());
734 }
735
736 #[test]
737 fn parse_discovered_skill_supports_frontmatter_and_fallback() {
738 let temp = tempdir().unwrap();
739 let skill_dir = temp.path().join("skills").join("demo");
740 fs::create_dir_all(&skill_dir).unwrap();
741
742 let fm_skill = skill_dir.join("SKILL.md");
743 fs::write(
744 &fm_skill,
745 "---\nname: Demo Skill\ndescription: Does demo things\nlicense: MIT\ncompatibility: rust\n---\n# Body\n",
746 )
747 .unwrap();
748
749 let parsed = parse_discovered_skill(&fm_skill).unwrap();
750 assert_eq!(parsed.name, "Demo Skill");
751 assert_eq!(parsed.description, "Does demo things");
752 assert_eq!(parsed.license.as_deref(), Some("MIT"));
753 assert_eq!(parsed.compatibility.as_deref(), Some("rust"));
754
755 let fallback_dir = temp.path().join("skills").join("fallback-skill");
756 fs::create_dir_all(&fallback_dir).unwrap();
757 let fallback_file = fallback_dir.join("SKILL.md");
758 fs::write(
759 &fallback_file,
760 "# Title\n\nFirst line of fallback description.\nSecond line.\n\n## Next section\n",
761 )
762 .unwrap();
763
764 let fallback = parse_discovered_skill(&fallback_file).unwrap();
765 assert_eq!(fallback.name, "fallback-skill");
766 assert_eq!(
767 fallback.description,
768 "First line of fallback description. Second line."
769 );
770 assert!(fallback.license.is_none());
771 assert!(fallback.compatibility.is_none());
772 }
773
774 #[test]
775 fn discover_skills_from_path_scans_known_locations_and_root() {
776 let temp = tempdir().unwrap();
777
778 let skill_paths = [
779 temp.path().join("skills").join("a").join("SKILL.md"),
780 temp.path()
781 .join(".agents/skills")
782 .join("b")
783 .join("SKILL.md"),
784 temp.path()
785 .join(".opencode/skills")
786 .join("c")
787 .join("SKILL.md"),
788 temp.path()
789 .join(".claude/skills")
790 .join("d")
791 .join("SKILL.md"),
792 temp.path().join("SKILL.md"),
793 ];
794
795 for path in &skill_paths {
796 fs::create_dir_all(path.parent().unwrap()).unwrap();
797 }
798
799 fs::write(
800 &skill_paths[0],
801 "---\nname: skill-a\ndescription: from skills\n---\n",
802 )
803 .unwrap();
804 fs::write(
805 &skill_paths[1],
806 "---\nname: skill-b\ndescription: from agents\n---\n",
807 )
808 .unwrap();
809 fs::write(
810 &skill_paths[2],
811 "---\nname: skill-c\ndescription: from opencode\n---\n",
812 )
813 .unwrap();
814 fs::write(
815 &skill_paths[3],
816 "---\nname: skill-d\ndescription: from claude\n---\n",
817 )
818 .unwrap();
819 fs::write(
820 &skill_paths[4],
821 "---\nname: root-skill\ndescription: from root\n---\n",
822 )
823 .unwrap();
824
825 let discovered = discover_skills_from_path(temp.path());
826 let mut names = discovered.into_iter().map(|s| s.name).collect::<Vec<_>>();
827 names.sort();
828 assert_eq!(
829 names,
830 vec![
831 "root-skill".to_string(),
832 "skill-a".to_string(),
833 "skill-b".to_string(),
834 "skill-c".to_string(),
835 "skill-d".to_string()
836 ]
837 );
838 }
839
840 #[test]
841 fn branch_to_safe_dir_name_replaces_unsafe_chars() {
842 assert_eq!(
843 branch_to_safe_dir_name("feature/new ui@2026"),
844 "feature-new-ui-2026"
845 );
846 assert_eq!(branch_to_safe_dir_name("release-1.2.3"), "release-1.2.3");
847 }
848
849 #[test]
850 fn copy_dir_recursive_skips_git_and_node_modules() {
851 let temp = tempdir().unwrap();
852 let src = temp.path().join("src");
853 let dest = temp.path().join("dest");
854
855 fs::create_dir_all(src.join(".git")).unwrap();
856 fs::create_dir_all(src.join("node_modules/pkg")).unwrap();
857 fs::create_dir_all(src.join("nested")).unwrap();
858
859 fs::write(src.join(".git/config"), "ignored").unwrap();
860 fs::write(src.join("node_modules/pkg/index.js"), "ignored").unwrap();
861 fs::write(src.join("nested/kept.txt"), "hello").unwrap();
862 fs::write(src.join("root.txt"), "root").unwrap();
863
864 copy_dir_recursive(&src, &dest).unwrap();
865
866 assert!(dest.join("root.txt").is_file());
867 assert!(dest.join("nested/kept.txt").is_file());
868 assert!(!dest.join(".git").exists());
869 assert!(!dest.join("node_modules").exists());
870 }
871}