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::cmp::Ordering;
7use std::collections::{BTreeSet, HashMap};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ParsedGitHubUrl {
13    pub owner: String,
14    pub repo: String,
15}
16
17/// Parse a GitHub URL or owner/repo shorthand.
18pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
19    let trimmed = url.trim();
20
21    let patterns = [
22        r"^https?://github\.com/([^/]+)/([^/\s#?.]+)",
23        r"^git@github\.com:([^/]+)/([^/\s#?.]+)",
24        r"^github\.com/([^/]+)/([^/\s#?.]+)",
25    ];
26
27    for pattern in &patterns {
28        if let Ok(re) = Regex::new(pattern) {
29            if let Some(caps) = re.captures(trimmed) {
30                let owner = caps.get(1)?.as_str().to_string();
31                let repo = caps.get(2)?.as_str().trim_end_matches(".git").to_string();
32                return Some(ParsedGitHubUrl { owner, repo });
33            }
34        }
35    }
36
37    if let Ok(re) = Regex::new(r"^([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)$") {
38        if let Some(caps) = re.captures(trimmed) {
39            if !trimmed.contains('\\') && !trimmed.contains(':') {
40                let owner = caps.get(1)?.as_str().to_string();
41                let repo = caps.get(2)?.as_str().to_string();
42                return Some(ParsedGitHubUrl { owner, repo });
43            }
44        }
45    }
46
47    None
48}
49
50/// Base directory for cloned repos.
51pub fn get_clone_base_dir() -> PathBuf {
52    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
53    if cwd.parent().is_none() {
54        if let Some(home) = dirs::home_dir() {
55            return home.join(".routa").join("repos");
56        }
57    }
58    cwd.join(".routa").join("repos")
59}
60
61pub fn repo_to_dir_name(owner: &str, repo: &str) -> String {
62    format!("{}--{}", owner, repo)
63}
64
65pub fn dir_name_to_repo(dir_name: &str) -> String {
66    let parts: Vec<&str> = dir_name.splitn(2, "--").collect();
67    if parts.len() == 2 {
68        format!("{}/{}", parts[0], parts[1])
69    } else {
70        dir_name.to_string()
71    }
72}
73
74pub fn is_git_repository(repo_path: &str) -> bool {
75    Command::new("git")
76        .args(["rev-parse", "--git-dir"])
77        .current_dir(repo_path)
78        .output()
79        .map(|o| o.status.success())
80        .unwrap_or(false)
81}
82
83pub fn get_current_branch(repo_path: &str) -> Option<String> {
84    let output = Command::new("git")
85        .args(["rev-parse", "--abbrev-ref", "HEAD"])
86        .current_dir(repo_path)
87        .output()
88        .ok()?;
89    if output.status.success() {
90        let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
91        if s.is_empty() {
92            None
93        } else {
94            Some(s)
95        }
96    } else {
97        None
98    }
99}
100
101pub fn list_local_branches(repo_path: &str) -> Vec<String> {
102    Command::new("git")
103        .args(["branch", "--format=%(refname:short)"])
104        .current_dir(repo_path)
105        .output()
106        .ok()
107        .filter(|o| o.status.success())
108        .map(|o| {
109            String::from_utf8_lossy(&o.stdout)
110                .lines()
111                .map(|l| l.trim().to_string())
112                .filter(|l| !l.is_empty())
113                .collect()
114        })
115        .unwrap_or_default()
116}
117
118pub fn list_remote_branches(repo_path: &str) -> Vec<String> {
119    Command::new("git")
120        .args(["branch", "-r", "--format=%(refname:short)"])
121        .current_dir(repo_path)
122        .output()
123        .ok()
124        .filter(|o| o.status.success())
125        .map(|o| {
126            String::from_utf8_lossy(&o.stdout)
127                .lines()
128                .map(|l| l.trim().to_string())
129                .filter(|l| !l.is_empty() && !l.contains("HEAD"))
130                .map(|l| l.trim_start_matches("origin/").to_string())
131                .collect()
132        })
133        .unwrap_or_default()
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct RepoBranchInfo {
138    pub current: String,
139    pub branches: Vec<String>,
140}
141
142pub fn get_branch_info(repo_path: &str) -> RepoBranchInfo {
143    RepoBranchInfo {
144        current: get_current_branch(repo_path).unwrap_or_else(|| "unknown".into()),
145        branches: list_local_branches(repo_path),
146    }
147}
148
149pub fn checkout_branch(repo_path: &str, branch: &str) -> bool {
150    let ok = Command::new("git")
151        .args(["checkout", branch])
152        .current_dir(repo_path)
153        .output()
154        .map(|o| o.status.success())
155        .unwrap_or(false);
156    if ok {
157        return true;
158    }
159    Command::new("git")
160        .args(["checkout", "-b", branch])
161        .current_dir(repo_path)
162        .output()
163        .map(|o| o.status.success())
164        .unwrap_or(false)
165}
166
167pub fn delete_branch(repo_path: &str, branch: &str) -> Result<(), String> {
168    let current_branch = get_current_branch(repo_path).unwrap_or_default();
169    if current_branch == branch {
170        return Err(format!("Cannot delete the current branch '{}'", branch));
171    }
172
173    if !list_local_branches(repo_path)
174        .iter()
175        .any(|candidate| candidate == branch)
176    {
177        return Err(format!("Branch '{}' not found", branch));
178    }
179
180    let output = Command::new("git")
181        .args(["branch", "-D", branch])
182        .current_dir(repo_path)
183        .output()
184        .map_err(|e| e.to_string())?;
185
186    if output.status.success() {
187        Ok(())
188    } else {
189        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
190    }
191}
192
193pub fn fetch_remote(repo_path: &str) -> bool {
194    Command::new("git")
195        .args(["fetch", "--all", "--prune"])
196        .current_dir(repo_path)
197        .output()
198        .map(|o| o.status.success())
199        .unwrap_or(false)
200}
201
202pub fn pull_branch(repo_path: &str) -> Result<(), String> {
203    let output = Command::new("git")
204        .args(["pull", "--ff-only"])
205        .current_dir(repo_path)
206        .output()
207        .map_err(|e| e.to_string())?;
208    if output.status.success() {
209        Ok(())
210    } else {
211        Err(String::from_utf8_lossy(&output.stderr).to_string())
212    }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct BranchStatus {
218    pub ahead: i32,
219    pub behind: i32,
220    pub has_uncommitted_changes: bool,
221}
222
223pub fn get_branch_status(repo_path: &str, branch: &str) -> BranchStatus {
224    let mut result = BranchStatus {
225        ahead: 0,
226        behind: 0,
227        has_uncommitted_changes: false,
228    };
229
230    if let Ok(o) = Command::new("git")
231        .args([
232            "rev-list",
233            "--left-right",
234            "--count",
235            &format!("{}...origin/{}", branch, branch),
236        ])
237        .current_dir(repo_path)
238        .output()
239    {
240        if o.status.success() {
241            let text = String::from_utf8_lossy(&o.stdout);
242            let parts: Vec<&str> = text.split_whitespace().collect();
243            if parts.len() == 2 {
244                result.ahead = parts[0].parse().unwrap_or(0);
245                result.behind = parts[1].parse().unwrap_or(0);
246            }
247        }
248    }
249
250    if let Ok(o) = Command::new("git")
251        .args(["status", "--porcelain", "-uall"])
252        .current_dir(repo_path)
253        .output()
254    {
255        if o.status.success() {
256            result.has_uncommitted_changes = !String::from_utf8_lossy(&o.stdout).trim().is_empty();
257        }
258    }
259
260    result
261}
262
263pub fn reset_local_changes(repo_path: &str) -> Result<(), String> {
264    let reset_output = Command::new("git")
265        .args(["reset", "--hard", "HEAD"])
266        .current_dir(repo_path)
267        .output()
268        .map_err(|e| e.to_string())?;
269    if !reset_output.status.success() {
270        return Err(String::from_utf8_lossy(&reset_output.stderr)
271            .trim()
272            .to_string());
273    }
274
275    let clean_output = Command::new("git")
276        .args(["clean", "-fd"])
277        .current_dir(repo_path)
278        .output()
279        .map_err(|e| e.to_string())?;
280    if !clean_output.status.success() {
281        return Err(String::from_utf8_lossy(&clean_output.stderr)
282            .trim()
283            .to_string());
284    }
285
286    Ok(())
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct RepoStatus {
292    pub clean: bool,
293    pub ahead: i32,
294    pub behind: i32,
295    pub modified: i32,
296    pub untracked: i32,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300#[serde(rename_all = "camelCase")]
301pub enum FileChangeStatus {
302    Modified,
303    Added,
304    Deleted,
305    Renamed,
306    Copied,
307    Untracked,
308    Typechange,
309    Conflicted,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "camelCase")]
314pub struct GitFileChange {
315    pub path: String,
316    pub status: FileChangeStatus,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub previous_path: Option<String>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct RepoChanges {
324    pub branch: String,
325    pub status: RepoStatus,
326    pub files: Vec<GitFileChange>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct HistoricalRelatedFile {
332    pub path: String,
333    pub score: f64,
334    pub source_files: Vec<String>,
335    pub related_commits: Vec<String>,
336}
337
338#[derive(Default)]
339struct HistoricalCandidateAggregate {
340    hits: u32,
341    source_files: BTreeSet<String>,
342    related_commits: BTreeSet<String>,
343}
344
345#[derive(Debug, Clone)]
346struct BlameChunk {
347    commit: String,
348    start: u32,
349    end: u32,
350}
351
352pub fn get_repo_status(repo_path: &str) -> RepoStatus {
353    let mut status = RepoStatus {
354        clean: true,
355        ahead: 0,
356        behind: 0,
357        modified: 0,
358        untracked: 0,
359    };
360
361    if let Ok(o) = Command::new("git")
362        .args(["status", "--porcelain", "-uall"])
363        .current_dir(repo_path)
364        .output()
365    {
366        if o.status.success() {
367            let text = String::from_utf8_lossy(&o.stdout);
368            let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
369            status.modified = lines.iter().filter(|l| !l.starts_with("??")).count() as i32;
370            status.untracked = lines.iter().filter(|l| l.starts_with("??")).count() as i32;
371            status.clean = lines.is_empty();
372        }
373    }
374
375    if let Ok(o) = Command::new("git")
376        .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
377        .current_dir(repo_path)
378        .output()
379    {
380        if o.status.success() {
381            let text = String::from_utf8_lossy(&o.stdout);
382            let parts: Vec<&str> = text.split_whitespace().collect();
383            if parts.len() == 2 {
384                status.ahead = parts[0].parse().unwrap_or(0);
385                status.behind = parts[1].parse().unwrap_or(0);
386            }
387        }
388    }
389
390    status
391}
392
393fn map_porcelain_status(code: &str) -> FileChangeStatus {
394    if code == "??" {
395        return FileChangeStatus::Untracked;
396    }
397
398    let mut chars = code.chars();
399    let index_status = chars.next().unwrap_or(' ');
400    let worktree_status = chars.next().unwrap_or(' ');
401
402    if index_status == 'U' || worktree_status == 'U' || code == "AA" || code == "DD" {
403        return FileChangeStatus::Conflicted;
404    }
405    if index_status == 'R' || worktree_status == 'R' {
406        return FileChangeStatus::Renamed;
407    }
408    if index_status == 'C' || worktree_status == 'C' {
409        return FileChangeStatus::Copied;
410    }
411    if index_status == 'A' || worktree_status == 'A' {
412        return FileChangeStatus::Added;
413    }
414    if index_status == 'D' || worktree_status == 'D' {
415        return FileChangeStatus::Deleted;
416    }
417    if index_status == 'T' || worktree_status == 'T' {
418        return FileChangeStatus::Typechange;
419    }
420    FileChangeStatus::Modified
421}
422
423pub fn parse_git_status_porcelain(output: &str) -> Vec<GitFileChange> {
424    output
425        .lines()
426        .filter(|line| !line.trim().is_empty())
427        .filter_map(|line| {
428            if line.len() < 3 {
429                return None;
430            }
431
432            let code = &line[0..2];
433            if code == "!!" {
434                return None;
435            }
436
437            let raw_path = line[3..].trim().to_string();
438            let status = map_porcelain_status(code);
439
440            if matches!(status, FileChangeStatus::Renamed | FileChangeStatus::Copied)
441                && raw_path.contains(" -> ")
442            {
443                let parts: Vec<&str> = raw_path.splitn(2, " -> ").collect();
444                if parts.len() == 2 {
445                    return Some(GitFileChange {
446                        path: parts[1].to_string(),
447                        previous_path: Some(parts[0].to_string()),
448                        status,
449                    });
450                }
451            }
452
453            Some(GitFileChange {
454                path: raw_path,
455                previous_path: None,
456                status,
457            })
458        })
459        .collect()
460}
461
462pub fn get_repo_changes(repo_path: &str) -> RepoChanges {
463    let branch = get_current_branch(repo_path).unwrap_or_else(|| "unknown".into());
464    let status = get_repo_status(repo_path);
465    let files = Command::new("git")
466        .args(["status", "--porcelain", "-uall"])
467        .current_dir(repo_path)
468        .output()
469        .ok()
470        .filter(|o| o.status.success())
471        .map(|o| parse_git_status_porcelain(&String::from_utf8_lossy(&o.stdout)))
472        .unwrap_or_default();
473
474    RepoChanges {
475        branch,
476        status,
477        files,
478    }
479}
480
481fn git_output_at_path(repo_root: &Path, args: &[&str]) -> Result<String, String> {
482    let output = Command::new("git")
483        .args(args)
484        .current_dir(repo_root)
485        .output()
486        .map_err(|err| format!("Failed to run git {}: {}", args.join(" "), err))?;
487
488    if output.status.success() {
489        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
490    } else {
491        Err(format!(
492            "git {} failed: {}",
493            args.join(" "),
494            String::from_utf8_lossy(&output.stderr).trim()
495        ))
496    }
497}
498
499/// Build historical co-change context for a review diff range.
500///
501/// The output is intentionally compact and best-effort friendly for review payloads.
502pub fn compute_historical_related_files(
503    repo_root: &Path,
504    diff_range: &str,
505    head: &str,
506    max_results: usize,
507) -> Result<Vec<HistoricalRelatedFile>, String> {
508    let changed_files: Vec<String> =
509        git_output_at_path(repo_root, &["diff", "--name-only", diff_range])?
510            .lines()
511            .map(str::trim)
512            .filter(|line| !line.is_empty())
513            .map(str::to_string)
514            .collect();
515
516    if changed_files.is_empty() {
517        return Ok(Vec::new());
518    }
519
520    let source_files: Vec<String> = changed_files.into_iter().take(8).collect();
521    let changed_file_set: BTreeSet<String> = source_files.iter().cloned().collect();
522    let mut candidate_map: HashMap<String, HistoricalCandidateAggregate> = HashMap::new();
523    let mut blame_cache: HashMap<String, Vec<BlameChunk>> = HashMap::new();
524    let mut commit_paths_cache: HashMap<String, Vec<String>> = HashMap::new();
525
526    for source_file in &source_files {
527        if !file_exists_at_revision(repo_root, head, source_file) {
528            continue;
529        }
530
531        let line_samples = collect_interesting_lines(repo_root, diff_range, source_file)?;
532        if line_samples.is_empty() {
533            continue;
534        }
535
536        let blame_chunks = load_blame_chunks(repo_root, head, source_file, &mut blame_cache)?;
537        if blame_chunks.is_empty() {
538            continue;
539        }
540
541        let mut interesting_commits: Vec<(String, u32)> =
542            collect_interesting_commits(&blame_chunks, &line_samples)
543                .into_iter()
544                .collect();
545        interesting_commits
546            .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
547        interesting_commits.truncate(8);
548
549        for (commit_sha, hits) in interesting_commits {
550            let changed_in_commit =
551                load_changed_files_for_commit(repo_root, &commit_sha, &mut commit_paths_cache)?;
552
553            for candidate_path in changed_in_commit {
554                if candidate_path.is_empty()
555                    || candidate_path == *source_file
556                    || changed_file_set.contains(&candidate_path)
557                {
558                    continue;
559                }
560
561                let entry = candidate_map.entry(candidate_path).or_default();
562                entry.hits = entry.hits.saturating_add(hits);
563                entry.source_files.insert(source_file.clone());
564                entry.related_commits.insert(commit_sha.clone());
565            }
566        }
567    }
568
569    if candidate_map.is_empty() {
570        return Ok(Vec::new());
571    }
572
573    let mut related_files: Vec<HistoricalRelatedFile> = candidate_map
574        .into_iter()
575        .map(|(path, aggregate)| HistoricalRelatedFile {
576            path,
577            score: aggregate.hits as f64,
578            source_files: aggregate.source_files.into_iter().collect(),
579            related_commits: aggregate.related_commits.into_iter().collect(),
580        })
581        .collect();
582
583    related_files.sort_by(|left, right| {
584        right
585            .score
586            .partial_cmp(&left.score)
587            .unwrap_or(Ordering::Equal)
588            .then_with(|| right.source_files.len().cmp(&left.source_files.len()))
589            .then_with(|| left.path.cmp(&right.path))
590    });
591
592    if max_results > 0 && related_files.len() > max_results {
593        related_files.truncate(max_results);
594    }
595
596    Ok(related_files)
597}
598
599fn file_exists_at_revision(repo_root: &Path, revision: &str, file_path: &str) -> bool {
600    Command::new("git")
601        .args(["cat-file", "-e", &format!("{}:{}", revision, file_path)])
602        .current_dir(repo_root)
603        .output()
604        .map(|output| output.status.success())
605        .unwrap_or(false)
606}
607
608fn collect_interesting_lines(
609    repo_root: &Path,
610    diff_range: &str,
611    file_path: &str,
612) -> Result<Vec<u32>, String> {
613    let raw_diff = git_output_at_path(
614        repo_root,
615        &["diff", "--unified=0", diff_range, "--", file_path],
616    )?;
617    if raw_diff.is_empty() {
618        return Ok(Vec::new());
619    }
620
621    let hunk_pattern = Regex::new(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
622        .map_err(|err| format!("Failed to compile diff hunk regex: {}", err))?;
623    let mut interesting_lines = BTreeSet::new();
624
625    for line in raw_diff.lines() {
626        let Some(captures) = hunk_pattern.captures(line) else {
627            continue;
628        };
629
630        let start = captures
631            .get(1)
632            .and_then(|value| value.as_str().parse::<u32>().ok())
633            .unwrap_or(0);
634        let count = captures
635            .get(2)
636            .and_then(|value| value.as_str().parse::<u32>().ok())
637            .unwrap_or(1);
638        let span = if count == 0 { 1 } else { count };
639        let end = start.saturating_add(span.saturating_sub(1));
640
641        for line_number in [start.saturating_sub(1), start, end, end.saturating_add(1)] {
642            if line_number > 0 {
643                interesting_lines.insert(line_number);
644            }
645        }
646    }
647
648    Ok(interesting_lines.into_iter().collect())
649}
650
651fn load_blame_chunks(
652    repo_root: &Path,
653    revision: &str,
654    file_path: &str,
655    cache: &mut HashMap<String, Vec<BlameChunk>>,
656) -> Result<Vec<BlameChunk>, String> {
657    let cache_key = format!("{}:{}", revision, file_path);
658    if let Some(chunks) = cache.get(&cache_key) {
659        return Ok(chunks.clone());
660    }
661
662    let raw_blame = match git_output_at_path(
663        repo_root,
664        &["blame", "--incremental", revision, "--", file_path],
665    ) {
666        Ok(output) => output,
667        Err(_) => {
668            cache.insert(cache_key, Vec::new());
669            return Ok(Vec::new());
670        }
671    };
672
673    let header_pattern = Regex::new(r"^([0-9a-f]{40}) \d+ (\d+) (\d+)$")
674        .map_err(|err| format!("Failed to compile blame regex: {}", err))?;
675    let mut chunks = Vec::new();
676    let mut current_chunk: Option<BlameChunk> = None;
677
678    for line in raw_blame.lines() {
679        if let Some(captures) = header_pattern.captures(line) {
680            let commit = captures
681                .get(1)
682                .map(|value| value.as_str().to_string())
683                .unwrap_or_default();
684            let start = captures
685                .get(2)
686                .and_then(|value| value.as_str().parse::<u32>().ok())
687                .unwrap_or(0);
688            let num_lines = captures
689                .get(3)
690                .and_then(|value| value.as_str().parse::<u32>().ok())
691                .unwrap_or(0);
692            current_chunk = Some(BlameChunk {
693                commit,
694                start,
695                end: start.saturating_add(num_lines),
696            });
697            continue;
698        }
699
700        if line.starts_with("filename ") {
701            if let Some(chunk) = current_chunk.take() {
702                chunks.push(chunk);
703            }
704        }
705    }
706
707    chunks.sort_by(|left, right| left.start.cmp(&right.start));
708    cache.insert(cache_key, chunks.clone());
709    Ok(chunks)
710}
711
712fn collect_interesting_commits(
713    blame_chunks: &[BlameChunk],
714    line_numbers: &[u32],
715) -> HashMap<String, u32> {
716    let mut commit_hits = HashMap::new();
717
718    for line_number in line_numbers {
719        if let Some(chunk) = blame_chunks
720            .iter()
721            .find(|candidate| *line_number >= candidate.start && *line_number < candidate.end)
722        {
723            *commit_hits.entry(chunk.commit.clone()).or_insert(0) += 1;
724        }
725    }
726
727    commit_hits
728}
729
730fn load_changed_files_for_commit(
731    repo_root: &Path,
732    commit: &str,
733    cache: &mut HashMap<String, Vec<String>>,
734) -> Result<Vec<String>, String> {
735    if let Some(files) = cache.get(commit) {
736        return Ok(files.clone());
737    }
738
739    let raw_files = match git_output_at_path(
740        repo_root,
741        &[
742            "diff-tree",
743            "--root",
744            "--no-commit-id",
745            "--name-only",
746            "-r",
747            "-m",
748            commit,
749        ],
750    ) {
751        Ok(output) => output,
752        Err(_) => {
753            cache.insert(commit.to_string(), Vec::new());
754            return Ok(Vec::new());
755        }
756    };
757
758    let files: Vec<String> = raw_files
759        .lines()
760        .map(str::trim)
761        .filter(|line| !line.is_empty())
762        .map(str::to_string)
763        .collect::<BTreeSet<_>>()
764        .into_iter()
765        .collect();
766    cache.insert(commit.to_string(), files.clone());
767    Ok(files)
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize)]
771#[serde(rename_all = "camelCase")]
772pub struct ClonedRepoInfo {
773    pub name: String,
774    pub path: String,
775    pub dir_name: String,
776    pub branch: String,
777    pub branches: Vec<String>,
778    pub status: RepoStatus,
779}
780
781/// List all cloned repos with branch and status info.
782pub fn list_cloned_repos() -> Vec<ClonedRepoInfo> {
783    let base_dir = get_clone_base_dir();
784    if !base_dir.exists() {
785        return vec![];
786    }
787
788    let entries = match std::fs::read_dir(&base_dir) {
789        Ok(e) => e,
790        Err(_) => return vec![],
791    };
792
793    entries
794        .flatten()
795        .filter(|e| e.path().is_dir())
796        .map(|e| {
797            let full_path = e.path();
798            let dir_name = e.file_name().to_string_lossy().to_string();
799            let path_str = full_path.to_string_lossy().to_string();
800            let branch_info = get_branch_info(&path_str);
801            let repo_status = get_repo_status(&path_str);
802            ClonedRepoInfo {
803                name: dir_name_to_repo(&dir_name),
804                path: path_str,
805                dir_name,
806                branch: branch_info.current,
807                branches: branch_info.branches,
808                status: repo_status,
809            }
810        })
811        .collect()
812}
813
814/// Discover skills from a given path (looks for SKILL.md files in well-known subdirectories).
815pub fn discover_skills_from_path(repo_path: &Path) -> Vec<DiscoveredSkill> {
816    let dirs_to_check = [
817        "skills",
818        ".agents/skills",
819        ".opencode/skills",
820        ".claude/skills",
821    ];
822
823    let mut result = Vec::new();
824
825    for dir in &dirs_to_check {
826        let skill_dir = repo_path.join(dir);
827        if skill_dir.is_dir() {
828            scan_skill_dir(&skill_dir, &mut result);
829        }
830    }
831
832    // Also check root-level SKILL.md
833    let root_skill = repo_path.join("SKILL.md");
834    if root_skill.is_file() {
835        if let Some(skill) = parse_discovered_skill(&root_skill) {
836            result.push(skill);
837        }
838    }
839
840    result
841}
842
843#[derive(Debug, Clone, Serialize, Deserialize)]
844#[serde(rename_all = "camelCase")]
845pub struct DiscoveredSkill {
846    pub name: String,
847    pub description: String,
848    pub source: String,
849    #[serde(skip_serializing_if = "Option::is_none")]
850    pub license: Option<String>,
851    #[serde(skip_serializing_if = "Option::is_none")]
852    pub compatibility: Option<String>,
853}
854
855fn scan_skill_dir(dir: &Path, out: &mut Vec<DiscoveredSkill>) {
856    let entries = match std::fs::read_dir(dir) {
857        Ok(e) => e,
858        Err(_) => return,
859    };
860
861    for entry in entries.flatten() {
862        let path = entry.path();
863        if path.is_dir() {
864            let skill_file = path.join("SKILL.md");
865            if skill_file.is_file() {
866                if let Some(skill) = parse_discovered_skill(&skill_file) {
867                    out.push(skill);
868                }
869            }
870        }
871    }
872}
873
874/// YAML frontmatter structure for discovered skills.
875#[derive(Debug, serde::Deserialize)]
876struct SkillFrontmatter {
877    name: String,
878    description: String,
879    #[serde(default)]
880    license: Option<String>,
881    #[serde(default)]
882    compatibility: Option<String>,
883}
884
885fn parse_discovered_skill(path: &Path) -> Option<DiscoveredSkill> {
886    let content = std::fs::read_to_string(path).ok()?;
887
888    // Try YAML frontmatter first
889    if let Some((fm_str, _body)) = extract_frontmatter_str(&content) {
890        if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&fm_str) {
891            return Some(DiscoveredSkill {
892                name: fm.name,
893                description: fm.description,
894                source: path.to_string_lossy().to_string(),
895                license: fm.license,
896                compatibility: fm.compatibility,
897            });
898        }
899    }
900
901    // Fallback: directory name + first paragraph
902    let name = path
903        .parent()
904        .and_then(|p| p.file_name())
905        .map(|n| n.to_string_lossy().to_string())
906        .unwrap_or_else(|| "unknown".into());
907
908    let description = content
909        .lines()
910        .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
911        .take_while(|l| !l.trim().is_empty())
912        .collect::<Vec<_>>()
913        .join(" ");
914
915    Some(DiscoveredSkill {
916        name,
917        description: if description.is_empty() {
918            "No description".into()
919        } else {
920            description
921        },
922        source: path.to_string_lossy().to_string(),
923        license: None,
924        compatibility: None,
925    })
926}
927
928#[cfg(test)]
929mod status_tests {
930    use super::{parse_git_status_porcelain, FileChangeStatus};
931
932    #[test]
933    fn parse_git_status_porcelain_maps_statuses() {
934        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";
935        let files = parse_git_status_porcelain(output);
936
937        assert_eq!(files.len(), 6);
938        assert_eq!(files[0].status, FileChangeStatus::Modified);
939        assert_eq!(files[1].status, FileChangeStatus::Added);
940        assert_eq!(files[2].status, FileChangeStatus::Deleted);
941        assert_eq!(files[3].status, FileChangeStatus::Renamed);
942        assert_eq!(files[3].previous_path.as_deref(), Some("src/was.ts"));
943        assert_eq!(files[3].path, "src/now.ts");
944        assert_eq!(files[4].status, FileChangeStatus::Untracked);
945        assert_eq!(files[5].status, FileChangeStatus::Conflicted);
946    }
947}
948
949/// Extract YAML frontmatter from between `---` delimiters.
950fn extract_frontmatter_str(contents: &str) -> Option<(String, String)> {
951    let mut lines = contents.lines();
952    if !matches!(lines.next(), Some(line) if line.trim() == "---") {
953        return None;
954    }
955
956    let mut frontmatter_lines: Vec<&str> = Vec::new();
957    let mut body_start = false;
958    let mut body_lines: Vec<&str> = Vec::new();
959
960    for line in lines {
961        if !body_start {
962            if line.trim() == "---" {
963                body_start = true;
964            } else {
965                frontmatter_lines.push(line);
966            }
967        } else {
968            body_lines.push(line);
969        }
970    }
971
972    if frontmatter_lines.is_empty() || !body_start {
973        return None;
974    }
975
976    Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
977}
978
979// ─── Git Worktree Operations ────────────────────────────────────────────
980
981/// Base directory for worktrees: ~/.routa/worktrees/
982pub fn get_worktree_base_dir() -> PathBuf {
983    dirs::home_dir()
984        .unwrap_or_else(|| PathBuf::from("."))
985        .join(".routa")
986        .join("worktrees")
987}
988
989/// Default worktree root for a workspace: ~/.routa/workspace/{workspaceId}
990pub fn get_default_workspace_worktree_root(workspace_id: &str) -> PathBuf {
991    dirs::home_dir()
992        .unwrap_or_else(|| PathBuf::from("."))
993        .join(".routa")
994        .join("workspace")
995        .join(workspace_id)
996}
997
998/// Sanitize a branch name for use as a directory name.
999pub fn branch_to_safe_dir_name(branch: &str) -> String {
1000    branch
1001        .chars()
1002        .map(|c| {
1003            if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' {
1004                c
1005            } else {
1006                '-'
1007            }
1008        })
1009        .collect()
1010}
1011
1012/// Prune stale worktree references.
1013pub fn worktree_prune(repo_path: &str) -> Result<(), String> {
1014    let output = Command::new("git")
1015        .args(["worktree", "prune"])
1016        .current_dir(repo_path)
1017        .output()
1018        .map_err(|e| e.to_string())?;
1019    if output.status.success() {
1020        Ok(())
1021    } else {
1022        Err(String::from_utf8_lossy(&output.stderr).to_string())
1023    }
1024}
1025
1026/// Add a new git worktree. If `create_branch` is true, creates a new branch.
1027pub fn worktree_add(
1028    repo_path: &str,
1029    worktree_path: &str,
1030    branch: &str,
1031    base_branch: &str,
1032    create_branch: bool,
1033) -> Result<(), String> {
1034    // Ensure parent directory exists
1035    if let Some(parent) = Path::new(worktree_path).parent() {
1036        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1037    }
1038
1039    let args = if create_branch {
1040        vec![
1041            "worktree".to_string(),
1042            "add".to_string(),
1043            "-b".to_string(),
1044            branch.to_string(),
1045            worktree_path.to_string(),
1046            base_branch.to_string(),
1047        ]
1048    } else {
1049        vec![
1050            "worktree".to_string(),
1051            "add".to_string(),
1052            worktree_path.to_string(),
1053            branch.to_string(),
1054        ]
1055    };
1056
1057    let output = Command::new("git")
1058        .args(&args)
1059        .current_dir(repo_path)
1060        .output()
1061        .map_err(|e| e.to_string())?;
1062
1063    if output.status.success() {
1064        Ok(())
1065    } else {
1066        Err(String::from_utf8_lossy(&output.stderr).to_string())
1067    }
1068}
1069
1070/// Remove a git worktree.
1071pub fn worktree_remove(repo_path: &str, worktree_path: &str, force: bool) -> Result<(), String> {
1072    let mut args = vec!["worktree", "remove"];
1073    if force {
1074        args.push("--force");
1075    }
1076    args.push(worktree_path);
1077
1078    let output = Command::new("git")
1079        .args(&args)
1080        .current_dir(repo_path)
1081        .output()
1082        .map_err(|e| e.to_string())?;
1083
1084    if output.status.success() {
1085        Ok(())
1086    } else {
1087        Err(String::from_utf8_lossy(&output.stderr).to_string())
1088    }
1089}
1090
1091#[derive(Debug, Clone, Serialize, Deserialize)]
1092#[serde(rename_all = "camelCase")]
1093pub struct WorktreeListEntry {
1094    pub path: String,
1095    pub head: String,
1096    pub branch: String,
1097}
1098
1099/// List all worktrees for a repository.
1100pub fn worktree_list(repo_path: &str) -> Vec<WorktreeListEntry> {
1101    let output = match Command::new("git")
1102        .args(["worktree", "list", "--porcelain"])
1103        .current_dir(repo_path)
1104        .output()
1105    {
1106        Ok(o) if o.status.success() => o,
1107        _ => return vec![],
1108    };
1109
1110    let text = String::from_utf8_lossy(&output.stdout);
1111    let mut entries = Vec::new();
1112    let mut current_path = String::new();
1113    let mut current_head = String::new();
1114    let mut current_branch = String::new();
1115
1116    for line in text.lines() {
1117        if let Some(p) = line.strip_prefix("worktree ") {
1118            if !current_path.is_empty() {
1119                entries.push(WorktreeListEntry {
1120                    path: std::mem::take(&mut current_path),
1121                    head: std::mem::take(&mut current_head),
1122                    branch: std::mem::take(&mut current_branch),
1123                });
1124            }
1125            current_path = p.to_string();
1126        } else if let Some(h) = line.strip_prefix("HEAD ") {
1127            current_head = h.to_string();
1128        } else if let Some(b) = line.strip_prefix("branch ") {
1129            // "refs/heads/branch-name" -> "branch-name"
1130            current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
1131        }
1132    }
1133
1134    // Push last entry
1135    if !current_path.is_empty() {
1136        entries.push(WorktreeListEntry {
1137            path: current_path,
1138            head: current_head,
1139            branch: current_branch,
1140        });
1141    }
1142
1143    entries
1144}
1145
1146/// Check if a local branch exists.
1147pub fn branch_exists(repo_path: &str, branch: &str) -> bool {
1148    Command::new("git")
1149        .args(["branch", "--list", branch])
1150        .current_dir(repo_path)
1151        .output()
1152        .ok()
1153        .filter(|o| o.status.success())
1154        .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
1155        .unwrap_or(false)
1156}
1157
1158/// Recursively copy a directory, skipping .git and node_modules.
1159pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
1160    std::fs::create_dir_all(dest)?;
1161    // Internal helper for copying already-resolved local skill directories.
1162    // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
1163    for entry in std::fs::read_dir(src)? {
1164        let entry = entry?;
1165        let src_path = entry.path();
1166        let dest_path = dest.join(entry.file_name());
1167
1168        if src_path.is_dir() {
1169            let name = entry.file_name();
1170            let name_str = name.to_string_lossy();
1171            if name_str == ".git" || name_str == "node_modules" {
1172                continue;
1173            }
1174            copy_dir_recursive(&src_path, &dest_path)?;
1175        } else {
1176            std::fs::copy(&src_path, &dest_path)?;
1177        }
1178    }
1179    Ok(())
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184    use super::*;
1185    use std::collections::HashSet;
1186    use std::fs;
1187    use std::path::Path;
1188    use std::process::Command;
1189    use tempfile::tempdir;
1190
1191    fn git(cwd: &Path, args: &[&str]) -> String {
1192        let output = Command::new("git")
1193            .args(args)
1194            .current_dir(cwd)
1195            .output()
1196            .expect("git command should run");
1197        assert!(
1198            output.status.success(),
1199            "git {:?} failed: {}",
1200            args,
1201            String::from_utf8_lossy(&output.stderr)
1202        );
1203        String::from_utf8_lossy(&output.stdout).trim().to_string()
1204    }
1205
1206    #[test]
1207    fn parse_github_url_supports_multiple_formats() {
1208        let https = parse_github_url("https://github.com/phodal/routa-js.git").unwrap();
1209        assert_eq!(https.owner, "phodal");
1210        assert_eq!(https.repo, "routa-js");
1211
1212        let ssh = parse_github_url("git@github.com:owner/repo-name.git").unwrap();
1213        assert_eq!(ssh.owner, "owner");
1214        assert_eq!(ssh.repo, "repo-name");
1215
1216        let shorthand = parse_github_url("foo/bar.baz").unwrap();
1217        assert_eq!(shorthand.owner, "foo");
1218        assert_eq!(shorthand.repo, "bar.baz");
1219
1220        assert!(parse_github_url(r"C:\tmp\repo").is_none());
1221    }
1222
1223    #[test]
1224    fn repo_dir_name_conversions_are_stable() {
1225        let dir = repo_to_dir_name("org", "project");
1226        assert_eq!(dir, "org--project");
1227        assert_eq!(dir_name_to_repo(&dir), "org/project");
1228        assert_eq!(dir_name_to_repo("no-separator"), "no-separator");
1229    }
1230
1231    #[test]
1232    fn frontmatter_extraction_requires_both_delimiters() {
1233        let content = "---\nname: demo\ndescription: hello\n---\nbody";
1234        let (fm, body) = extract_frontmatter_str(content).unwrap();
1235        assert!(fm.contains("name: demo"));
1236        assert_eq!(body, "body");
1237
1238        assert!(extract_frontmatter_str("name: x\n---\nbody").is_none());
1239        assert!(extract_frontmatter_str("---\nname: x\nbody").is_none());
1240    }
1241
1242    #[test]
1243    fn parse_discovered_skill_supports_frontmatter_and_fallback() {
1244        let temp = tempdir().unwrap();
1245        let skill_dir = temp.path().join("skills").join("demo");
1246        fs::create_dir_all(&skill_dir).unwrap();
1247
1248        let fm_skill = skill_dir.join("SKILL.md");
1249        fs::write(
1250            &fm_skill,
1251            "---\nname: Demo Skill\ndescription: Does demo things\nlicense: MIT\ncompatibility: rust\n---\n# Body\n",
1252        )
1253        .unwrap();
1254
1255        let parsed = parse_discovered_skill(&fm_skill).unwrap();
1256        assert_eq!(parsed.name, "Demo Skill");
1257        assert_eq!(parsed.description, "Does demo things");
1258        assert_eq!(parsed.license.as_deref(), Some("MIT"));
1259        assert_eq!(parsed.compatibility.as_deref(), Some("rust"));
1260
1261        let fallback_dir = temp.path().join("skills").join("fallback-skill");
1262        fs::create_dir_all(&fallback_dir).unwrap();
1263        let fallback_file = fallback_dir.join("SKILL.md");
1264        fs::write(
1265            &fallback_file,
1266            "# Title\n\nFirst line of fallback description.\nSecond line.\n\n## Next section\n",
1267        )
1268        .unwrap();
1269
1270        let fallback = parse_discovered_skill(&fallback_file).unwrap();
1271        assert_eq!(fallback.name, "fallback-skill");
1272        assert_eq!(
1273            fallback.description,
1274            "First line of fallback description. Second line."
1275        );
1276        assert!(fallback.license.is_none());
1277        assert!(fallback.compatibility.is_none());
1278    }
1279
1280    #[test]
1281    fn discover_skills_from_path_scans_known_locations_and_root() {
1282        let temp = tempdir().unwrap();
1283
1284        let skill_paths = [
1285            temp.path().join("skills").join("a").join("SKILL.md"),
1286            temp.path()
1287                .join(".agents/skills")
1288                .join("b")
1289                .join("SKILL.md"),
1290            temp.path()
1291                .join(".opencode/skills")
1292                .join("c")
1293                .join("SKILL.md"),
1294            temp.path()
1295                .join(".claude/skills")
1296                .join("d")
1297                .join("SKILL.md"),
1298            temp.path().join("SKILL.md"),
1299        ];
1300
1301        for path in &skill_paths {
1302            fs::create_dir_all(path.parent().unwrap()).unwrap();
1303        }
1304
1305        fs::write(
1306            &skill_paths[0],
1307            "---\nname: skill-a\ndescription: from skills\n---\n",
1308        )
1309        .unwrap();
1310        fs::write(
1311            &skill_paths[1],
1312            "---\nname: skill-b\ndescription: from agents\n---\n",
1313        )
1314        .unwrap();
1315        fs::write(
1316            &skill_paths[2],
1317            "---\nname: skill-c\ndescription: from opencode\n---\n",
1318        )
1319        .unwrap();
1320        fs::write(
1321            &skill_paths[3],
1322            "---\nname: skill-d\ndescription: from claude\n---\n",
1323        )
1324        .unwrap();
1325        fs::write(
1326            &skill_paths[4],
1327            "---\nname: root-skill\ndescription: from root\n---\n",
1328        )
1329        .unwrap();
1330
1331        let discovered = discover_skills_from_path(temp.path());
1332        let mut names = discovered.into_iter().map(|s| s.name).collect::<Vec<_>>();
1333        names.sort();
1334        assert_eq!(
1335            names,
1336            vec![
1337                "root-skill".to_string(),
1338                "skill-a".to_string(),
1339                "skill-b".to_string(),
1340                "skill-c".to_string(),
1341                "skill-d".to_string()
1342            ]
1343        );
1344    }
1345
1346    #[test]
1347    fn branch_to_safe_dir_name_replaces_unsafe_chars() {
1348        assert_eq!(
1349            branch_to_safe_dir_name("feature/new ui@2026"),
1350            "feature-new-ui-2026"
1351        );
1352        assert_eq!(branch_to_safe_dir_name("release-1.2.3"), "release-1.2.3");
1353    }
1354
1355    #[test]
1356    fn compute_historical_related_files_collects_cochange_context() {
1357        let temp = tempdir().unwrap();
1358        let repo = temp.path();
1359
1360        git(repo, &["init", "-b", "main"]);
1361        // Use --local to scope test credentials to this repo only
1362        git(repo, &["config", "--local", "user.name", "Routa Test"]);
1363        git(
1364            repo,
1365            &["config", "--local", "user.email", "test@example.com"],
1366        );
1367
1368        fs::write(
1369            repo.join("example.ts"),
1370            "import { helper } from './helper';\nexport const value = helper(1);\nexport const trailing = 'stable';\n",
1371        )
1372        .unwrap();
1373        fs::write(
1374            repo.join("helper.ts"),
1375            "export function helper(input: number): number {\n  return input + 1;\n}\n",
1376        )
1377        .unwrap();
1378        git(repo, &["add", "."]);
1379        git(
1380            repo,
1381            &[
1382                "-c",
1383                "commit.gpgSign=false",
1384                "commit",
1385                "-m",
1386                "initial shared context",
1387            ],
1388        );
1389
1390        fs::write(
1391            repo.join("example.ts"),
1392            "import { helper } from './helper';\nexport const value = helper(2);\nexport const trailing = 'stable';\n",
1393        )
1394        .unwrap();
1395        git(repo, &["add", "example.ts"]);
1396        git(
1397            repo,
1398            &[
1399                "-c",
1400                "commit.gpgSign=false",
1401                "commit",
1402                "-m",
1403                "update example only",
1404            ],
1405        );
1406
1407        let related = compute_historical_related_files(repo, "HEAD~1..HEAD", "HEAD", 20).unwrap();
1408        assert!(!related.is_empty());
1409
1410        let mut unique_paths = HashSet::new();
1411        for item in &related {
1412            assert!(unique_paths.insert(item.path.clone()));
1413            assert!(item.score > 0.0);
1414            assert!(!item.source_files.is_empty());
1415            assert!(!item.related_commits.is_empty());
1416        }
1417
1418        let helper = related
1419            .iter()
1420            .find(|item| item.path == "helper.ts")
1421            .expect("helper.ts should be suggested");
1422        assert_eq!(helper.source_files, vec!["example.ts".to_string()]);
1423        assert_eq!(helper.related_commits.len(), 1);
1424    }
1425
1426    #[test]
1427    fn compute_historical_related_files_handles_deleted_files_without_failing() {
1428        let temp = tempdir().unwrap();
1429        let repo = temp.path();
1430
1431        git(repo, &["init", "-b", "main"]);
1432        git(repo, &["config", "user.name", "Routa Test"]);
1433        git(repo, &["config", "user.email", "test@example.com"]);
1434
1435        fs::write(repo.join("keep.rs"), "pub fn keep() {}\n").unwrap();
1436        fs::write(repo.join("drop.rs"), "pub fn drop() {}\n").unwrap();
1437        git(repo, &["add", "."]);
1438        git(
1439            repo,
1440            &["-c", "commit.gpgSign=false", "commit", "-m", "initial"],
1441        );
1442
1443        fs::write(
1444            repo.join("keep.rs"),
1445            "pub fn keep() { println!(\"keep\"); }\n",
1446        )
1447        .unwrap();
1448        fs::remove_file(repo.join("drop.rs")).unwrap();
1449        git(repo, &["add", "-A"]);
1450        git(
1451            repo,
1452            &["-c", "commit.gpgSign=false", "commit", "-m", "delete drop"],
1453        );
1454
1455        let related = compute_historical_related_files(repo, "HEAD~1..HEAD", "HEAD", 20).unwrap();
1456        assert!(related.is_empty());
1457    }
1458
1459    #[test]
1460    fn copy_dir_recursive_skips_git_and_node_modules() {
1461        let temp = tempdir().unwrap();
1462        let src = temp.path().join("src");
1463        let dest = temp.path().join("dest");
1464
1465        fs::create_dir_all(src.join(".git")).unwrap();
1466        fs::create_dir_all(src.join("node_modules/pkg")).unwrap();
1467        fs::create_dir_all(src.join("nested")).unwrap();
1468
1469        fs::write(src.join(".git/config"), "ignored").unwrap();
1470        fs::write(src.join("node_modules/pkg/index.js"), "ignored").unwrap();
1471        fs::write(src.join("nested/kept.txt"), "hello").unwrap();
1472        fs::write(src.join("root.txt"), "root").unwrap();
1473
1474        copy_dir_recursive(&src, &dest).unwrap();
1475
1476        assert!(dest.join("root.txt").is_file());
1477        assert!(dest.join("nested/kept.txt").is_file());
1478        assert!(!dest.join(".git").exists());
1479        assert!(!dest.join("node_modules").exists());
1480    }
1481}