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 is_git_repository(repo_path: &str) -> bool {
73    Command::new("git")
74        .args(["rev-parse", "--git-dir"])
75        .current_dir(repo_path)
76        .output()
77        .map(|o| o.status.success())
78        .unwrap_or(false)
79}
80
81pub fn get_current_branch(repo_path: &str) -> Option<String> {
82    let output = Command::new("git")
83        .args(["rev-parse", "--abbrev-ref", "HEAD"])
84        .current_dir(repo_path)
85        .output()
86        .ok()?;
87    if output.status.success() {
88        let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
89        if s.is_empty() {
90            None
91        } else {
92            Some(s)
93        }
94    } else {
95        None
96    }
97}
98
99pub fn list_local_branches(repo_path: &str) -> Vec<String> {
100    Command::new("git")
101        .args(["branch", "--format=%(refname:short)"])
102        .current_dir(repo_path)
103        .output()
104        .ok()
105        .filter(|o| o.status.success())
106        .map(|o| {
107            String::from_utf8_lossy(&o.stdout)
108                .lines()
109                .map(|l| l.trim().to_string())
110                .filter(|l| !l.is_empty())
111                .collect()
112        })
113        .unwrap_or_default()
114}
115
116pub fn list_remote_branches(repo_path: &str) -> Vec<String> {
117    Command::new("git")
118        .args(["branch", "-r", "--format=%(refname:short)"])
119        .current_dir(repo_path)
120        .output()
121        .ok()
122        .filter(|o| o.status.success())
123        .map(|o| {
124            String::from_utf8_lossy(&o.stdout)
125                .lines()
126                .map(|l| l.trim().to_string())
127                .filter(|l| !l.is_empty() && !l.contains("HEAD"))
128                .map(|l| l.trim_start_matches("origin/").to_string())
129                .collect()
130        })
131        .unwrap_or_default()
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct RepoBranchInfo {
136    pub current: String,
137    pub branches: Vec<String>,
138}
139
140pub fn get_branch_info(repo_path: &str) -> RepoBranchInfo {
141    RepoBranchInfo {
142        current: get_current_branch(repo_path).unwrap_or_else(|| "unknown".into()),
143        branches: list_local_branches(repo_path),
144    }
145}
146
147pub fn checkout_branch(repo_path: &str, branch: &str) -> bool {
148    let ok = Command::new("git")
149        .args(["checkout", branch])
150        .current_dir(repo_path)
151        .output()
152        .map(|o| o.status.success())
153        .unwrap_or(false);
154    if ok {
155        return true;
156    }
157    Command::new("git")
158        .args(["checkout", "-b", branch])
159        .current_dir(repo_path)
160        .output()
161        .map(|o| o.status.success())
162        .unwrap_or(false)
163}
164
165pub fn fetch_remote(repo_path: &str) -> bool {
166    Command::new("git")
167        .args(["fetch", "--all", "--prune"])
168        .current_dir(repo_path)
169        .output()
170        .map(|o| o.status.success())
171        .unwrap_or(false)
172}
173
174pub fn pull_branch(repo_path: &str) -> Result<(), String> {
175    let output = Command::new("git")
176        .args(["pull", "--ff-only"])
177        .current_dir(repo_path)
178        .output()
179        .map_err(|e| e.to_string())?;
180    if output.status.success() {
181        Ok(())
182    } else {
183        Err(String::from_utf8_lossy(&output.stderr).to_string())
184    }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct BranchStatus {
190    pub ahead: i32,
191    pub behind: i32,
192    pub has_uncommitted_changes: bool,
193}
194
195pub fn get_branch_status(repo_path: &str, branch: &str) -> BranchStatus {
196    let mut result = BranchStatus {
197        ahead: 0,
198        behind: 0,
199        has_uncommitted_changes: false,
200    };
201
202    if let Ok(o) = Command::new("git")
203        .args([
204            "rev-list",
205            "--left-right",
206            "--count",
207            &format!("{}...origin/{}", branch, branch),
208        ])
209        .current_dir(repo_path)
210        .output()
211    {
212        if o.status.success() {
213            let text = String::from_utf8_lossy(&o.stdout);
214            let parts: Vec<&str> = text.split_whitespace().collect();
215            if parts.len() == 2 {
216                result.ahead = parts[0].parse().unwrap_or(0);
217                result.behind = parts[1].parse().unwrap_or(0);
218            }
219        }
220    }
221
222    if let Ok(o) = Command::new("git")
223        .args(["status", "--porcelain", "-uall"])
224        .current_dir(repo_path)
225        .output()
226    {
227        if o.status.success() {
228            result.has_uncommitted_changes = !String::from_utf8_lossy(&o.stdout).trim().is_empty();
229        }
230    }
231
232    result
233}
234
235pub fn reset_local_changes(repo_path: &str) -> Result<(), String> {
236    let reset_output = Command::new("git")
237        .args(["reset", "--hard", "HEAD"])
238        .current_dir(repo_path)
239        .output()
240        .map_err(|e| e.to_string())?;
241    if !reset_output.status.success() {
242        return Err(String::from_utf8_lossy(&reset_output.stderr)
243            .trim()
244            .to_string());
245    }
246
247    let clean_output = Command::new("git")
248        .args(["clean", "-fd"])
249        .current_dir(repo_path)
250        .output()
251        .map_err(|e| e.to_string())?;
252    if !clean_output.status.success() {
253        return Err(String::from_utf8_lossy(&clean_output.stderr)
254            .trim()
255            .to_string());
256    }
257
258    Ok(())
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct RepoStatus {
264    pub clean: bool,
265    pub ahead: i32,
266    pub behind: i32,
267    pub modified: i32,
268    pub untracked: i32,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
272#[serde(rename_all = "camelCase")]
273pub enum FileChangeStatus {
274    Modified,
275    Added,
276    Deleted,
277    Renamed,
278    Copied,
279    Untracked,
280    Typechange,
281    Conflicted,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
285#[serde(rename_all = "camelCase")]
286pub struct GitFileChange {
287    pub path: String,
288    pub status: FileChangeStatus,
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub previous_path: Option<String>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct RepoChanges {
296    pub branch: String,
297    pub status: RepoStatus,
298    pub files: Vec<GitFileChange>,
299}
300
301pub fn get_repo_status(repo_path: &str) -> RepoStatus {
302    let mut status = RepoStatus {
303        clean: true,
304        ahead: 0,
305        behind: 0,
306        modified: 0,
307        untracked: 0,
308    };
309
310    if let Ok(o) = Command::new("git")
311        .args(["status", "--porcelain", "-uall"])
312        .current_dir(repo_path)
313        .output()
314    {
315        if o.status.success() {
316            let text = String::from_utf8_lossy(&o.stdout);
317            let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
318            status.modified = lines.iter().filter(|l| !l.starts_with("??")).count() as i32;
319            status.untracked = lines.iter().filter(|l| l.starts_with("??")).count() as i32;
320            status.clean = lines.is_empty();
321        }
322    }
323
324    if let Ok(o) = Command::new("git")
325        .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
326        .current_dir(repo_path)
327        .output()
328    {
329        if o.status.success() {
330            let text = String::from_utf8_lossy(&o.stdout);
331            let parts: Vec<&str> = text.split_whitespace().collect();
332            if parts.len() == 2 {
333                status.ahead = parts[0].parse().unwrap_or(0);
334                status.behind = parts[1].parse().unwrap_or(0);
335            }
336        }
337    }
338
339    status
340}
341
342fn map_porcelain_status(code: &str) -> FileChangeStatus {
343    if code == "??" {
344        return FileChangeStatus::Untracked;
345    }
346
347    let mut chars = code.chars();
348    let index_status = chars.next().unwrap_or(' ');
349    let worktree_status = chars.next().unwrap_or(' ');
350
351    if index_status == 'U' || worktree_status == 'U' || code == "AA" || code == "DD" {
352        return FileChangeStatus::Conflicted;
353    }
354    if index_status == 'R' || worktree_status == 'R' {
355        return FileChangeStatus::Renamed;
356    }
357    if index_status == 'C' || worktree_status == 'C' {
358        return FileChangeStatus::Copied;
359    }
360    if index_status == 'A' || worktree_status == 'A' {
361        return FileChangeStatus::Added;
362    }
363    if index_status == 'D' || worktree_status == 'D' {
364        return FileChangeStatus::Deleted;
365    }
366    if index_status == 'T' || worktree_status == 'T' {
367        return FileChangeStatus::Typechange;
368    }
369    FileChangeStatus::Modified
370}
371
372pub fn parse_git_status_porcelain(output: &str) -> Vec<GitFileChange> {
373    output
374        .lines()
375        .filter(|line| !line.trim().is_empty())
376        .filter_map(|line| {
377            if line.len() < 3 {
378                return None;
379            }
380
381            let code = &line[0..2];
382            if code == "!!" {
383                return None;
384            }
385
386            let raw_path = line[3..].trim().to_string();
387            let status = map_porcelain_status(code);
388
389            if matches!(status, FileChangeStatus::Renamed | FileChangeStatus::Copied)
390                && raw_path.contains(" -> ")
391            {
392                let parts: Vec<&str> = raw_path.splitn(2, " -> ").collect();
393                if parts.len() == 2 {
394                    return Some(GitFileChange {
395                        path: parts[1].to_string(),
396                        previous_path: Some(parts[0].to_string()),
397                        status,
398                    });
399                }
400            }
401
402            Some(GitFileChange {
403                path: raw_path,
404                previous_path: None,
405                status,
406            })
407        })
408        .collect()
409}
410
411pub fn get_repo_changes(repo_path: &str) -> RepoChanges {
412    let branch = get_current_branch(repo_path).unwrap_or_else(|| "unknown".into());
413    let status = get_repo_status(repo_path);
414    let files = Command::new("git")
415        .args(["status", "--porcelain", "-uall"])
416        .current_dir(repo_path)
417        .output()
418        .ok()
419        .filter(|o| o.status.success())
420        .map(|o| parse_git_status_porcelain(&String::from_utf8_lossy(&o.stdout)))
421        .unwrap_or_default();
422
423    RepoChanges {
424        branch,
425        status,
426        files,
427    }
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[serde(rename_all = "camelCase")]
432pub struct ClonedRepoInfo {
433    pub name: String,
434    pub path: String,
435    pub dir_name: String,
436    pub branch: String,
437    pub branches: Vec<String>,
438    pub status: RepoStatus,
439}
440
441/// List all cloned repos with branch and status info.
442pub fn list_cloned_repos() -> Vec<ClonedRepoInfo> {
443    let base_dir = get_clone_base_dir();
444    if !base_dir.exists() {
445        return vec![];
446    }
447
448    let entries = match std::fs::read_dir(&base_dir) {
449        Ok(e) => e,
450        Err(_) => return vec![],
451    };
452
453    entries
454        .flatten()
455        .filter(|e| e.path().is_dir())
456        .map(|e| {
457            let full_path = e.path();
458            let dir_name = e.file_name().to_string_lossy().to_string();
459            let path_str = full_path.to_string_lossy().to_string();
460            let branch_info = get_branch_info(&path_str);
461            let repo_status = get_repo_status(&path_str);
462            ClonedRepoInfo {
463                name: dir_name_to_repo(&dir_name),
464                path: path_str,
465                dir_name,
466                branch: branch_info.current,
467                branches: branch_info.branches,
468                status: repo_status,
469            }
470        })
471        .collect()
472}
473
474/// Discover skills from a given path (looks for SKILL.md files in well-known subdirectories).
475pub fn discover_skills_from_path(repo_path: &Path) -> Vec<DiscoveredSkill> {
476    let dirs_to_check = [
477        "skills",
478        ".agents/skills",
479        ".opencode/skills",
480        ".claude/skills",
481    ];
482
483    let mut result = Vec::new();
484
485    for dir in &dirs_to_check {
486        let skill_dir = repo_path.join(dir);
487        if skill_dir.is_dir() {
488            scan_skill_dir(&skill_dir, &mut result);
489        }
490    }
491
492    // Also check root-level SKILL.md
493    let root_skill = repo_path.join("SKILL.md");
494    if root_skill.is_file() {
495        if let Some(skill) = parse_discovered_skill(&root_skill) {
496            result.push(skill);
497        }
498    }
499
500    result
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(rename_all = "camelCase")]
505pub struct DiscoveredSkill {
506    pub name: String,
507    pub description: String,
508    pub source: String,
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub license: Option<String>,
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub compatibility: Option<String>,
513}
514
515fn scan_skill_dir(dir: &Path, out: &mut Vec<DiscoveredSkill>) {
516    let entries = match std::fs::read_dir(dir) {
517        Ok(e) => e,
518        Err(_) => return,
519    };
520
521    for entry in entries.flatten() {
522        let path = entry.path();
523        if path.is_dir() {
524            let skill_file = path.join("SKILL.md");
525            if skill_file.is_file() {
526                if let Some(skill) = parse_discovered_skill(&skill_file) {
527                    out.push(skill);
528                }
529            }
530        }
531    }
532}
533
534/// YAML frontmatter structure for discovered skills.
535#[derive(Debug, serde::Deserialize)]
536struct SkillFrontmatter {
537    name: String,
538    description: String,
539    #[serde(default)]
540    license: Option<String>,
541    #[serde(default)]
542    compatibility: Option<String>,
543}
544
545fn parse_discovered_skill(path: &Path) -> Option<DiscoveredSkill> {
546    let content = std::fs::read_to_string(path).ok()?;
547
548    // Try YAML frontmatter first
549    if let Some((fm_str, _body)) = extract_frontmatter_str(&content) {
550        if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&fm_str) {
551            return Some(DiscoveredSkill {
552                name: fm.name,
553                description: fm.description,
554                source: path.to_string_lossy().to_string(),
555                license: fm.license,
556                compatibility: fm.compatibility,
557            });
558        }
559    }
560
561    // Fallback: directory name + first paragraph
562    let name = path
563        .parent()
564        .and_then(|p| p.file_name())
565        .map(|n| n.to_string_lossy().to_string())
566        .unwrap_or_else(|| "unknown".into());
567
568    let description = content
569        .lines()
570        .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
571        .take_while(|l| !l.trim().is_empty())
572        .collect::<Vec<_>>()
573        .join(" ");
574
575    Some(DiscoveredSkill {
576        name,
577        description: if description.is_empty() {
578            "No description".into()
579        } else {
580            description
581        },
582        source: path.to_string_lossy().to_string(),
583        license: None,
584        compatibility: None,
585    })
586}
587
588#[cfg(test)]
589mod status_tests {
590    use super::{parse_git_status_porcelain, FileChangeStatus};
591
592    #[test]
593    fn parse_git_status_porcelain_maps_statuses() {
594        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";
595        let files = parse_git_status_porcelain(output);
596
597        assert_eq!(files.len(), 6);
598        assert_eq!(files[0].status, FileChangeStatus::Modified);
599        assert_eq!(files[1].status, FileChangeStatus::Added);
600        assert_eq!(files[2].status, FileChangeStatus::Deleted);
601        assert_eq!(files[3].status, FileChangeStatus::Renamed);
602        assert_eq!(files[3].previous_path.as_deref(), Some("src/was.ts"));
603        assert_eq!(files[3].path, "src/now.ts");
604        assert_eq!(files[4].status, FileChangeStatus::Untracked);
605        assert_eq!(files[5].status, FileChangeStatus::Conflicted);
606    }
607}
608
609/// Extract YAML frontmatter from between `---` delimiters.
610fn extract_frontmatter_str(contents: &str) -> Option<(String, String)> {
611    let mut lines = contents.lines();
612    if !matches!(lines.next(), Some(line) if line.trim() == "---") {
613        return None;
614    }
615
616    let mut frontmatter_lines: Vec<&str> = Vec::new();
617    let mut body_start = false;
618    let mut body_lines: Vec<&str> = Vec::new();
619
620    for line in lines {
621        if !body_start {
622            if line.trim() == "---" {
623                body_start = true;
624            } else {
625                frontmatter_lines.push(line);
626            }
627        } else {
628            body_lines.push(line);
629        }
630    }
631
632    if frontmatter_lines.is_empty() || !body_start {
633        return None;
634    }
635
636    Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
637}
638
639// ─── Git Worktree Operations ────────────────────────────────────────────
640
641/// Base directory for worktrees: ~/.routa/worktrees/
642pub fn get_worktree_base_dir() -> PathBuf {
643    dirs::home_dir()
644        .unwrap_or_else(|| PathBuf::from("."))
645        .join(".routa")
646        .join("worktrees")
647}
648
649/// Default worktree root for a workspace: ~/.routa/workspace/{workspaceId}
650pub fn get_default_workspace_worktree_root(workspace_id: &str) -> PathBuf {
651    dirs::home_dir()
652        .unwrap_or_else(|| PathBuf::from("."))
653        .join(".routa")
654        .join("workspace")
655        .join(workspace_id)
656}
657
658/// Sanitize a branch name for use as a directory name.
659pub fn branch_to_safe_dir_name(branch: &str) -> String {
660    branch
661        .chars()
662        .map(|c| {
663            if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' {
664                c
665            } else {
666                '-'
667            }
668        })
669        .collect()
670}
671
672/// Prune stale worktree references.
673pub fn worktree_prune(repo_path: &str) -> Result<(), String> {
674    let output = Command::new("git")
675        .args(["worktree", "prune"])
676        .current_dir(repo_path)
677        .output()
678        .map_err(|e| e.to_string())?;
679    if output.status.success() {
680        Ok(())
681    } else {
682        Err(String::from_utf8_lossy(&output.stderr).to_string())
683    }
684}
685
686/// Add a new git worktree. If `create_branch` is true, creates a new branch.
687pub fn worktree_add(
688    repo_path: &str,
689    worktree_path: &str,
690    branch: &str,
691    base_branch: &str,
692    create_branch: bool,
693) -> Result<(), String> {
694    // Ensure parent directory exists
695    if let Some(parent) = Path::new(worktree_path).parent() {
696        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
697    }
698
699    let args = if create_branch {
700        vec![
701            "worktree".to_string(),
702            "add".to_string(),
703            "-b".to_string(),
704            branch.to_string(),
705            worktree_path.to_string(),
706            base_branch.to_string(),
707        ]
708    } else {
709        vec![
710            "worktree".to_string(),
711            "add".to_string(),
712            worktree_path.to_string(),
713            branch.to_string(),
714        ]
715    };
716
717    let output = Command::new("git")
718        .args(&args)
719        .current_dir(repo_path)
720        .output()
721        .map_err(|e| e.to_string())?;
722
723    if output.status.success() {
724        Ok(())
725    } else {
726        Err(String::from_utf8_lossy(&output.stderr).to_string())
727    }
728}
729
730/// Remove a git worktree.
731pub fn worktree_remove(repo_path: &str, worktree_path: &str, force: bool) -> Result<(), String> {
732    let mut args = vec!["worktree", "remove"];
733    if force {
734        args.push("--force");
735    }
736    args.push(worktree_path);
737
738    let output = Command::new("git")
739        .args(&args)
740        .current_dir(repo_path)
741        .output()
742        .map_err(|e| e.to_string())?;
743
744    if output.status.success() {
745        Ok(())
746    } else {
747        Err(String::from_utf8_lossy(&output.stderr).to_string())
748    }
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
752#[serde(rename_all = "camelCase")]
753pub struct WorktreeListEntry {
754    pub path: String,
755    pub head: String,
756    pub branch: String,
757}
758
759/// List all worktrees for a repository.
760pub fn worktree_list(repo_path: &str) -> Vec<WorktreeListEntry> {
761    let output = match Command::new("git")
762        .args(["worktree", "list", "--porcelain"])
763        .current_dir(repo_path)
764        .output()
765    {
766        Ok(o) if o.status.success() => o,
767        _ => return vec![],
768    };
769
770    let text = String::from_utf8_lossy(&output.stdout);
771    let mut entries = Vec::new();
772    let mut current_path = String::new();
773    let mut current_head = String::new();
774    let mut current_branch = String::new();
775
776    for line in text.lines() {
777        if let Some(p) = line.strip_prefix("worktree ") {
778            if !current_path.is_empty() {
779                entries.push(WorktreeListEntry {
780                    path: std::mem::take(&mut current_path),
781                    head: std::mem::take(&mut current_head),
782                    branch: std::mem::take(&mut current_branch),
783                });
784            }
785            current_path = p.to_string();
786        } else if let Some(h) = line.strip_prefix("HEAD ") {
787            current_head = h.to_string();
788        } else if let Some(b) = line.strip_prefix("branch ") {
789            // "refs/heads/branch-name" -> "branch-name"
790            current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
791        }
792    }
793
794    // Push last entry
795    if !current_path.is_empty() {
796        entries.push(WorktreeListEntry {
797            path: current_path,
798            head: current_head,
799            branch: current_branch,
800        });
801    }
802
803    entries
804}
805
806/// Check if a local branch exists.
807pub fn branch_exists(repo_path: &str, branch: &str) -> bool {
808    Command::new("git")
809        .args(["branch", "--list", branch])
810        .current_dir(repo_path)
811        .output()
812        .ok()
813        .filter(|o| o.status.success())
814        .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
815        .unwrap_or(false)
816}
817
818/// Recursively copy a directory, skipping .git and node_modules.
819pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
820    std::fs::create_dir_all(dest)?;
821    // Internal helper for copying already-resolved local skill directories.
822    // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
823    for entry in std::fs::read_dir(src)? {
824        let entry = entry?;
825        let src_path = entry.path();
826        let dest_path = dest.join(entry.file_name());
827
828        if src_path.is_dir() {
829            let name = entry.file_name();
830            let name_str = name.to_string_lossy();
831            if name_str == ".git" || name_str == "node_modules" {
832                continue;
833            }
834            copy_dir_recursive(&src_path, &dest_path)?;
835        } else {
836            std::fs::copy(&src_path, &dest_path)?;
837        }
838    }
839    Ok(())
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use std::fs;
846    use tempfile::tempdir;
847
848    #[test]
849    fn parse_github_url_supports_multiple_formats() {
850        let https = parse_github_url("https://github.com/phodal/routa-js.git").unwrap();
851        assert_eq!(https.owner, "phodal");
852        assert_eq!(https.repo, "routa-js");
853
854        let ssh = parse_github_url("git@github.com:owner/repo-name.git").unwrap();
855        assert_eq!(ssh.owner, "owner");
856        assert_eq!(ssh.repo, "repo-name");
857
858        let shorthand = parse_github_url("foo/bar.baz").unwrap();
859        assert_eq!(shorthand.owner, "foo");
860        assert_eq!(shorthand.repo, "bar.baz");
861
862        assert!(parse_github_url(r"C:\tmp\repo").is_none());
863    }
864
865    #[test]
866    fn repo_dir_name_conversions_are_stable() {
867        let dir = repo_to_dir_name("org", "project");
868        assert_eq!(dir, "org--project");
869        assert_eq!(dir_name_to_repo(&dir), "org/project");
870        assert_eq!(dir_name_to_repo("no-separator"), "no-separator");
871    }
872
873    #[test]
874    fn frontmatter_extraction_requires_both_delimiters() {
875        let content = "---\nname: demo\ndescription: hello\n---\nbody";
876        let (fm, body) = extract_frontmatter_str(content).unwrap();
877        assert!(fm.contains("name: demo"));
878        assert_eq!(body, "body");
879
880        assert!(extract_frontmatter_str("name: x\n---\nbody").is_none());
881        assert!(extract_frontmatter_str("---\nname: x\nbody").is_none());
882    }
883
884    #[test]
885    fn parse_discovered_skill_supports_frontmatter_and_fallback() {
886        let temp = tempdir().unwrap();
887        let skill_dir = temp.path().join("skills").join("demo");
888        fs::create_dir_all(&skill_dir).unwrap();
889
890        let fm_skill = skill_dir.join("SKILL.md");
891        fs::write(
892            &fm_skill,
893            "---\nname: Demo Skill\ndescription: Does demo things\nlicense: MIT\ncompatibility: rust\n---\n# Body\n",
894        )
895        .unwrap();
896
897        let parsed = parse_discovered_skill(&fm_skill).unwrap();
898        assert_eq!(parsed.name, "Demo Skill");
899        assert_eq!(parsed.description, "Does demo things");
900        assert_eq!(parsed.license.as_deref(), Some("MIT"));
901        assert_eq!(parsed.compatibility.as_deref(), Some("rust"));
902
903        let fallback_dir = temp.path().join("skills").join("fallback-skill");
904        fs::create_dir_all(&fallback_dir).unwrap();
905        let fallback_file = fallback_dir.join("SKILL.md");
906        fs::write(
907            &fallback_file,
908            "# Title\n\nFirst line of fallback description.\nSecond line.\n\n## Next section\n",
909        )
910        .unwrap();
911
912        let fallback = parse_discovered_skill(&fallback_file).unwrap();
913        assert_eq!(fallback.name, "fallback-skill");
914        assert_eq!(
915            fallback.description,
916            "First line of fallback description. Second line."
917        );
918        assert!(fallback.license.is_none());
919        assert!(fallback.compatibility.is_none());
920    }
921
922    #[test]
923    fn discover_skills_from_path_scans_known_locations_and_root() {
924        let temp = tempdir().unwrap();
925
926        let skill_paths = [
927            temp.path().join("skills").join("a").join("SKILL.md"),
928            temp.path()
929                .join(".agents/skills")
930                .join("b")
931                .join("SKILL.md"),
932            temp.path()
933                .join(".opencode/skills")
934                .join("c")
935                .join("SKILL.md"),
936            temp.path()
937                .join(".claude/skills")
938                .join("d")
939                .join("SKILL.md"),
940            temp.path().join("SKILL.md"),
941        ];
942
943        for path in &skill_paths {
944            fs::create_dir_all(path.parent().unwrap()).unwrap();
945        }
946
947        fs::write(
948            &skill_paths[0],
949            "---\nname: skill-a\ndescription: from skills\n---\n",
950        )
951        .unwrap();
952        fs::write(
953            &skill_paths[1],
954            "---\nname: skill-b\ndescription: from agents\n---\n",
955        )
956        .unwrap();
957        fs::write(
958            &skill_paths[2],
959            "---\nname: skill-c\ndescription: from opencode\n---\n",
960        )
961        .unwrap();
962        fs::write(
963            &skill_paths[3],
964            "---\nname: skill-d\ndescription: from claude\n---\n",
965        )
966        .unwrap();
967        fs::write(
968            &skill_paths[4],
969            "---\nname: root-skill\ndescription: from root\n---\n",
970        )
971        .unwrap();
972
973        let discovered = discover_skills_from_path(temp.path());
974        let mut names = discovered.into_iter().map(|s| s.name).collect::<Vec<_>>();
975        names.sort();
976        assert_eq!(
977            names,
978            vec![
979                "root-skill".to_string(),
980                "skill-a".to_string(),
981                "skill-b".to_string(),
982                "skill-c".to_string(),
983                "skill-d".to_string()
984            ]
985        );
986    }
987
988    #[test]
989    fn branch_to_safe_dir_name_replaces_unsafe_chars() {
990        assert_eq!(
991            branch_to_safe_dir_name("feature/new ui@2026"),
992            "feature-new-ui-2026"
993        );
994        assert_eq!(branch_to_safe_dir_name("release-1.2.3"), "release-1.2.3");
995    }
996
997    #[test]
998    fn copy_dir_recursive_skips_git_and_node_modules() {
999        let temp = tempdir().unwrap();
1000        let src = temp.path().join("src");
1001        let dest = temp.path().join("dest");
1002
1003        fs::create_dir_all(src.join(".git")).unwrap();
1004        fs::create_dir_all(src.join("node_modules/pkg")).unwrap();
1005        fs::create_dir_all(src.join("nested")).unwrap();
1006
1007        fs::write(src.join(".git/config"), "ignored").unwrap();
1008        fs::write(src.join("node_modules/pkg/index.js"), "ignored").unwrap();
1009        fs::write(src.join("nested/kept.txt"), "hello").unwrap();
1010        fs::write(src.join("root.txt"), "root").unwrap();
1011
1012        copy_dir_recursive(&src, &dest).unwrap();
1013
1014        assert!(dest.join("root.txt").is_file());
1015        assert!(dest.join("nested/kept.txt").is_file());
1016        assert!(!dest.join(".git").exists());
1017        assert!(!dest.join("node_modules").exists());
1018    }
1019}