Skip to main content

routa_core/
git.rs

1//! Git utilities for clone, branch management, and repo inspection.
2//! Port of src/core/git/git-utils.ts
3
4use 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
15/// Parse a GitHub URL or owner/repo shorthand.
16pub 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
48/// Base directory for cloned repos.
49pub 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
314/// List all cloned repos with branch and status info.
315pub 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
347/// Discover skills from a given path (looks for SKILL.md files in well-known subdirectories).
348pub 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    // Also check root-level SKILL.md
366    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/// YAML frontmatter structure for discovered skills.
408#[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    // Try YAML frontmatter first
422    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    // Fallback: directory name + first paragraph
435    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
461/// Extract YAML frontmatter from between `---` delimiters.
462fn 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
491// ─── Git Worktree Operations ────────────────────────────────────────────
492
493/// Base directory for worktrees: ~/.routa/worktrees/
494pub fn get_worktree_base_dir() -> PathBuf {
495    dirs::home_dir()
496        .unwrap_or_else(|| PathBuf::from("."))
497        .join(".routa")
498        .join("worktrees")
499}
500
501/// Default worktree root for a workspace: ~/.routa/workspace/{workspaceId}
502pub 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
510/// Sanitize a branch name for use as a directory name.
511pub 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
524/// Prune stale worktree references.
525pub 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
538/// Add a new git worktree. If `create_branch` is true, creates a new branch.
539pub 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    // Ensure parent directory exists
547    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
582/// Remove a git worktree.
583pub 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
611/// List all worktrees for a repository.
612pub 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            // "refs/heads/branch-name" -> "branch-name"
642            current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
643        }
644    }
645
646    // Push last entry
647    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
658/// Check if a local branch exists.
659pub 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
670/// Recursively copy a directory, skipping .git and node_modules.
671pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
672    std::fs::create_dir_all(dest)?;
673    // Internal helper for copying already-resolved local skill directories.
674    // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
675    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}