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::{Component, Path, PathBuf};
9use std::process::Command;
10
11const GIT_LOG_SEARCH_SCAN_LIMIT: usize = 2000;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ParsedGitHubUrl {
15    pub owner: String,
16    pub repo: String,
17}
18
19/// Parse a GitHub URL or owner/repo shorthand.
20pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
21    let trimmed = url.trim();
22
23    let patterns = [
24        r"^https?://github\.com/([^/]+)/([^/\s#?.]+)",
25        r"^git@github\.com:([^/]+)/([^/\s#?.]+)",
26        r"^github\.com/([^/]+)/([^/\s#?.]+)",
27    ];
28
29    for pattern in &patterns {
30        if let Ok(re) = Regex::new(pattern) {
31            if let Some(caps) = re.captures(trimmed) {
32                let owner = caps.get(1)?.as_str().to_string();
33                let repo = caps.get(2)?.as_str().trim_end_matches(".git").to_string();
34                return Some(ParsedGitHubUrl { owner, repo });
35            }
36        }
37    }
38
39    if let Ok(re) = Regex::new(r"^([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)$") {
40        if let Some(caps) = re.captures(trimmed) {
41            if !trimmed.contains('\\') && !trimmed.contains(':') {
42                let owner = caps.get(1)?.as_str().to_string();
43                let repo = caps.get(2)?.as_str().to_string();
44                return Some(ParsedGitHubUrl { owner, repo });
45            }
46        }
47    }
48
49    None
50}
51
52/// Base directory for cloned repos.
53pub fn get_clone_base_dir() -> PathBuf {
54    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
55    if cwd.parent().is_none() {
56        if let Some(home) = dirs::home_dir() {
57            return home.join(".routa").join("repos");
58        }
59    }
60    cwd.join(".routa").join("repos")
61}
62
63pub fn repo_to_dir_name(owner: &str, repo: &str) -> String {
64    format!("{}--{}", owner, repo)
65}
66
67pub fn dir_name_to_repo(dir_name: &str) -> String {
68    let parts: Vec<&str> = dir_name.splitn(2, "--").collect();
69    if parts.len() == 2 {
70        format!("{}/{}", parts[0], parts[1])
71    } else {
72        dir_name.to_string()
73    }
74}
75
76pub fn is_git_repository(repo_path: &str) -> bool {
77    Command::new("git")
78        .args(["rev-parse", "--git-dir"])
79        .current_dir(repo_path)
80        .output()
81        .map(|o| o.status.success())
82        .unwrap_or(false)
83}
84
85pub fn get_current_branch(repo_path: &str) -> Option<String> {
86    let output = Command::new("git")
87        .args(["rev-parse", "--abbrev-ref", "HEAD"])
88        .current_dir(repo_path)
89        .output()
90        .ok()?;
91    if output.status.success() {
92        let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
93        if s.is_empty() {
94            None
95        } else {
96            Some(s)
97        }
98    } else {
99        None
100    }
101}
102
103pub fn list_local_branches(repo_path: &str) -> Vec<String> {
104    Command::new("git")
105        .args(["branch", "--format=%(refname:short)"])
106        .current_dir(repo_path)
107        .output()
108        .ok()
109        .filter(|o| o.status.success())
110        .map(|o| {
111            String::from_utf8_lossy(&o.stdout)
112                .lines()
113                .map(|l| l.trim().to_string())
114                .filter(|l| !l.is_empty())
115                .collect()
116        })
117        .unwrap_or_default()
118}
119
120pub fn list_remote_branches(repo_path: &str) -> Vec<String> {
121    Command::new("git")
122        .args(["branch", "-r", "--format=%(refname:short)"])
123        .current_dir(repo_path)
124        .output()
125        .ok()
126        .filter(|o| o.status.success())
127        .map(|o| {
128            String::from_utf8_lossy(&o.stdout)
129                .lines()
130                .map(|l| l.trim().to_string())
131                .filter(|l| !l.is_empty() && !l.contains("HEAD"))
132                .map(|l| l.trim_start_matches("origin/").to_string())
133                .collect()
134        })
135        .unwrap_or_default()
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct RepoBranchInfo {
140    pub current: String,
141    pub branches: Vec<String>,
142}
143
144pub fn get_branch_info(repo_path: &str) -> RepoBranchInfo {
145    RepoBranchInfo {
146        current: get_current_branch(repo_path).unwrap_or_else(|| "unknown".into()),
147        branches: list_local_branches(repo_path),
148    }
149}
150
151pub fn checkout_branch(repo_path: &str, branch: &str) -> bool {
152    let ok = Command::new("git")
153        .args(["checkout", branch])
154        .current_dir(repo_path)
155        .output()
156        .map(|o| o.status.success())
157        .unwrap_or(false);
158    if ok {
159        return true;
160    }
161    Command::new("git")
162        .args(["checkout", "-b", branch])
163        .current_dir(repo_path)
164        .output()
165        .map(|o| o.status.success())
166        .unwrap_or(false)
167}
168
169pub fn delete_branch(repo_path: &str, branch: &str) -> Result<(), String> {
170    let current_branch = get_current_branch(repo_path).unwrap_or_default();
171    if current_branch == branch {
172        return Err(format!("Cannot delete the current branch '{}'", branch));
173    }
174
175    if !list_local_branches(repo_path)
176        .iter()
177        .any(|candidate| candidate == branch)
178    {
179        return Err(format!("Branch '{}' not found", branch));
180    }
181
182    let output = Command::new("git")
183        .args(["branch", "-D", branch])
184        .current_dir(repo_path)
185        .output()
186        .map_err(|e| e.to_string())?;
187
188    if output.status.success() {
189        Ok(())
190    } else {
191        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
192    }
193}
194
195pub fn fetch_remote(repo_path: &str) -> bool {
196    Command::new("git")
197        .args(["fetch", "--all", "--prune"])
198        .current_dir(repo_path)
199        .output()
200        .map(|o| o.status.success())
201        .unwrap_or(false)
202}
203
204pub fn pull_branch(repo_path: &str) -> Result<(), String> {
205    let output = Command::new("git")
206        .args(["pull", "--ff-only"])
207        .current_dir(repo_path)
208        .output()
209        .map_err(|e| e.to_string())?;
210    if output.status.success() {
211        Ok(())
212    } else {
213        Err(String::from_utf8_lossy(&output.stderr).to_string())
214    }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct BranchStatus {
220    pub ahead: i32,
221    pub behind: i32,
222    pub has_uncommitted_changes: bool,
223}
224
225pub fn get_branch_status(repo_path: &str, branch: &str) -> BranchStatus {
226    let mut result = BranchStatus {
227        ahead: 0,
228        behind: 0,
229        has_uncommitted_changes: false,
230    };
231
232    // Build the range string separately to ensure proper handling of branch names with slashes
233    let range = format!("{}...origin/{}", branch, branch);
234
235    if let Ok(o) = Command::new("git")
236        .args(["rev-list", "--left-right", "--count", &range])
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        // Silently ignore errors - upstream may not exist or branch may not be on remote
249    }
250
251    if let Ok(o) = Command::new("git")
252        .args(["status", "--porcelain", "-uall"])
253        .current_dir(repo_path)
254        .output()
255    {
256        if o.status.success() {
257            result.has_uncommitted_changes = !String::from_utf8_lossy(&o.stdout).trim().is_empty();
258        }
259    }
260
261    result
262}
263
264pub fn reset_local_changes(repo_path: &str) -> Result<(), String> {
265    let reset_output = Command::new("git")
266        .args(["reset", "--hard", "HEAD"])
267        .current_dir(repo_path)
268        .output()
269        .map_err(|e| e.to_string())?;
270    if !reset_output.status.success() {
271        return Err(String::from_utf8_lossy(&reset_output.stderr)
272            .trim()
273            .to_string());
274    }
275
276    let clean_output = Command::new("git")
277        .args(["clean", "-fd"])
278        .current_dir(repo_path)
279        .output()
280        .map_err(|e| e.to_string())?;
281    if !clean_output.status.success() {
282        return Err(String::from_utf8_lossy(&clean_output.stderr)
283            .trim()
284            .to_string());
285    }
286
287    Ok(())
288}
289
290fn validate_git_paths(files: &[String]) -> Result<(), String> {
291    for file in files {
292        if file.trim().is_empty() {
293            return Err("File path cannot be empty".to_string());
294        }
295
296        let path = Path::new(file);
297        if path.is_absolute() {
298            return Err(format!("Absolute file paths are not allowed: {}", file));
299        }
300
301        if path.components().any(|component| {
302            matches!(
303                component,
304                Component::ParentDir | Component::RootDir | Component::Prefix(_)
305            )
306        }) {
307            return Err(format!(
308                "File paths must stay within the repository root: {}",
309                file
310            ));
311        }
312    }
313
314    Ok(())
315}
316
317// ============================================================================
318// Git Workflow Operations (for enhanced kanban file changes UI)
319// ============================================================================
320
321/// Stage files in the Git index
322pub fn stage_files(repo_path: &str, files: &[String]) -> Result<(), String> {
323    if files.is_empty() {
324        return Ok(());
325    }
326    validate_git_paths(files)?;
327
328    let mut args = vec!["add", "--"];
329    args.extend(files.iter().map(|s| s.as_str()));
330
331    let output = Command::new("git")
332        .args(&args)
333        .current_dir(repo_path)
334        .output()
335        .map_err(|e| e.to_string())?;
336
337    if !output.status.success() {
338        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
339    }
340
341    Ok(())
342}
343
344/// Unstage files from the Git index (keep working directory changes)
345pub fn unstage_files(repo_path: &str, files: &[String]) -> Result<(), String> {
346    if files.is_empty() {
347        return Ok(());
348    }
349    validate_git_paths(files)?;
350
351    let mut args = vec!["restore", "--staged", "--"];
352    args.extend(files.iter().map(|s| s.as_str()));
353
354    let output = Command::new("git")
355        .args(&args)
356        .current_dir(repo_path)
357        .output()
358        .map_err(|e| e.to_string())?;
359
360    if !output.status.success() {
361        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
362    }
363
364    Ok(())
365}
366
367/// Discard changes to files in working directory
368/// WARNING: This is destructive and cannot be undone
369pub fn discard_changes(repo_path: &str, files: &[String]) -> Result<(), String> {
370    if files.is_empty() {
371        return Ok(());
372    }
373    validate_git_paths(files)?;
374
375    let mut tracked_files: Vec<&str> = Vec::new();
376    let mut untracked_files: Vec<&str> = Vec::new();
377
378    for file in files {
379        let output = Command::new("git")
380            .args(["ls-files", "--error-unmatch", "--", file.as_str()])
381            .current_dir(repo_path)
382            .output()
383            .map_err(|e| e.to_string())?;
384
385        if output.status.success() {
386            tracked_files.push(file.as_str());
387        } else {
388            untracked_files.push(file.as_str());
389        }
390    }
391
392    if !tracked_files.is_empty() {
393        let mut restore_args = vec!["restore", "--"];
394        restore_args.extend(tracked_files);
395
396        let restore_output = Command::new("git")
397            .args(&restore_args)
398            .current_dir(repo_path)
399            .output()
400            .map_err(|e| e.to_string())?;
401
402        if !restore_output.status.success() {
403            return Err(String::from_utf8_lossy(&restore_output.stderr)
404                .trim()
405                .to_string());
406        }
407    }
408
409    if !untracked_files.is_empty() {
410        let mut clean_args = vec!["clean", "-f", "--"];
411        clean_args.extend(untracked_files);
412
413        let clean_output = Command::new("git")
414            .args(&clean_args)
415            .current_dir(repo_path)
416            .output()
417            .map_err(|e| e.to_string())?;
418
419        if !clean_output.status.success() {
420            return Err(String::from_utf8_lossy(&clean_output.stderr)
421                .trim()
422                .to_string());
423        }
424    }
425
426    Ok(())
427}
428
429/// Create a commit with the given message
430/// If files are provided, stages them first
431/// Returns the SHA of the created commit
432pub fn create_commit(
433    repo_path: &str,
434    message: &str,
435    files: Option<&[String]>,
436) -> Result<String, String> {
437    if message.trim().is_empty() {
438        return Err("Commit message cannot be empty".to_string());
439    }
440
441    // Stage specific files if provided
442    if let Some(file_list) = files {
443        validate_git_paths(file_list)?;
444        stage_files(repo_path, file_list)?;
445    }
446
447    // Check if there are staged changes
448    let check_output = Command::new("git")
449        .args(["diff", "--cached", "--name-only"])
450        .current_dir(repo_path)
451        .output()
452        .map_err(|e| e.to_string())?;
453
454    if check_output.stdout.is_empty() {
455        return Err("No staged changes to commit".to_string());
456    }
457
458    // Create the commit
459    let commit_output = Command::new("git")
460        .args(["commit", "-m", message])
461        .current_dir(repo_path)
462        .output()
463        .map_err(|e| e.to_string())?;
464
465    if !commit_output.status.success() {
466        return Err(String::from_utf8_lossy(&commit_output.stderr)
467            .trim()
468            .to_string());
469    }
470
471    // Get the commit SHA
472    let sha_output = Command::new("git")
473        .args(["rev-parse", "HEAD"])
474        .current_dir(repo_path)
475        .output()
476        .map_err(|e| e.to_string())?;
477
478    Ok(String::from_utf8_lossy(&sha_output.stdout)
479        .trim()
480        .to_string())
481}
482
483/// Pull commits from remote
484pub fn pull_commits(
485    repo_path: &str,
486    remote: Option<&str>,
487    branch: Option<&str>,
488) -> Result<(), String> {
489    let remote_name = remote.unwrap_or("origin");
490    let mut args = vec!["pull", remote_name];
491
492    if let Some(branch_name) = branch {
493        args.push(branch_name);
494    }
495
496    let output = Command::new("git")
497        .args(&args)
498        .current_dir(repo_path)
499        .output()
500        .map_err(|e| e.to_string())?;
501
502    if !output.status.success() {
503        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
504    }
505
506    Ok(())
507}
508
509/// Rebase current branch onto target branch
510pub fn rebase_branch(repo_path: &str, onto: &str) -> Result<(), String> {
511    let output = Command::new("git")
512        .args(["rebase", onto])
513        .current_dir(repo_path)
514        .output()
515        .map_err(|e| e.to_string())?;
516
517    if !output.status.success() {
518        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
519    }
520
521    Ok(())
522}
523
524/// Reset branch to a specific commit or branch
525/// mode: "soft" keeps changes staged, "hard" discards all changes
526pub fn reset_branch(
527    repo_path: &str,
528    to: &str,
529    mode: &str,
530    confirm_destructive: bool,
531) -> Result<(), String> {
532    let reset_mode = match mode {
533        "hard" => "--hard",
534        "soft" => "--soft",
535        other => {
536            return Err(format!(
537                "Invalid reset mode '{}'. Expected 'soft' or 'hard'",
538                other
539            ))
540        }
541    };
542
543    if mode == "hard" && !confirm_destructive {
544        return Err("Hard reset requires explicit destructive confirmation".to_string());
545    }
546
547    let output = Command::new("git")
548        .args(["reset", reset_mode, to])
549        .current_dir(repo_path)
550        .output()
551        .map_err(|e| e.to_string())?;
552
553    if !output.status.success() {
554        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
555    }
556
557    Ok(())
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
561#[serde(rename_all = "camelCase")]
562pub struct CommitInfo {
563    pub sha: String,
564    pub short_sha: String,
565    pub message: String,
566    pub summary: String,
567    pub author_name: String,
568    pub author_email: String,
569    pub authored_at: String,
570    pub additions: i32,
571    pub deletions: i32,
572    pub parents: Vec<String>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
576#[serde(rename_all = "camelCase")]
577pub enum GitRefKind {
578    Head,
579    Local,
580    Remote,
581    Tag,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
585#[serde(rename_all = "camelCase")]
586pub struct GitLogRef {
587    pub name: String,
588    pub remote: Option<String>,
589    pub kind: GitRefKind,
590    pub commit_sha: String,
591    pub is_current: Option<bool>,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
595#[serde(rename_all = "camelCase")]
596pub struct GitGraphEdge {
597    pub from_lane: i32,
598    pub to_lane: i32,
599    pub is_merge: Option<bool>,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
603#[serde(rename_all = "camelCase")]
604pub struct GitLogCommit {
605    pub sha: String,
606    pub short_sha: String,
607    pub message: String,
608    pub summary: String,
609    pub author_name: String,
610    pub author_email: String,
611    pub authored_at: String,
612    pub parents: Vec<String>,
613    pub refs: Vec<GitLogRef>,
614    pub lane: Option<i32>,
615    pub graph_edges: Option<Vec<GitGraphEdge>>,
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize)]
619#[serde(rename_all = "camelCase")]
620pub struct GitLogPage {
621    pub commits: Vec<GitLogCommit>,
622    pub total: usize,
623    pub has_more: bool,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
627#[serde(rename_all = "camelCase")]
628pub struct GitRefsResult {
629    pub head: Option<GitLogRef>,
630    pub local: Vec<GitLogRef>,
631    pub remote: Vec<GitLogRef>,
632    pub tags: Vec<GitLogRef>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
636#[serde(rename_all = "camelCase")]
637pub enum CommitFileChangeKind {
638    Added,
639    Modified,
640    Deleted,
641    Renamed,
642    Copied,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
646#[serde(rename_all = "camelCase")]
647pub struct GitCommitFileChange {
648    pub path: String,
649    pub previous_path: Option<String>,
650    pub status: CommitFileChangeKind,
651    pub additions: i32,
652    pub deletions: i32,
653}
654
655#[derive(Debug, Clone, Serialize, Deserialize)]
656#[serde(rename_all = "camelCase")]
657pub struct GitCommitDetail {
658    pub commit: GitLogCommit,
659    pub files: Vec<GitCommitFileChange>,
660    pub patch: Option<String>,
661}
662
663fn git_refs_map(refs: &GitRefsResult) -> HashMap<String, Vec<GitLogRef>> {
664    let mut map: HashMap<String, Vec<GitLogRef>> = HashMap::new();
665
666    for git_ref in refs
667        .head
668        .iter()
669        .cloned()
670        .chain(refs.local.iter().cloned())
671        .chain(refs.remote.iter().cloned())
672        .chain(refs.tags.iter().cloned())
673    {
674        map.entry(git_ref.commit_sha.clone())
675            .or_default()
676            .push(git_ref);
677    }
678
679    map
680}
681
682fn parse_git_log_records(
683    output: &str,
684    ref_map: &HashMap<String, Vec<GitLogRef>>,
685) -> Vec<GitLogCommit> {
686    output
687        .split('\0')
688        .map(str::trim)
689        .filter(|record| !record.is_empty())
690        .filter_map(|record| {
691            let parts: Vec<&str> = record.split('\u{001f}').collect();
692            if parts.len() < 7 {
693                return None;
694            }
695
696            let sha = parts[0].trim();
697            let short_sha = parts[1].trim();
698            let summary = parts[2].trim();
699            let author_name = parts[3].trim();
700            let author_email = parts[4].trim();
701            let authored_at = parts[5].trim();
702            let parents: Vec<String> = parts[6].split_whitespace().map(str::to_string).collect();
703
704            if sha.is_empty()
705                || short_sha.is_empty()
706                || summary.is_empty()
707                || author_name.is_empty()
708                || authored_at.is_empty()
709            {
710                return None;
711            }
712
713            let lane = if parents.len() > 1 { 1 } else { 0 };
714            let graph_edges = if parents.len() > 1 {
715                vec![
716                    GitGraphEdge {
717                        from_lane: 1,
718                        to_lane: 0,
719                        is_merge: Some(true),
720                    },
721                    GitGraphEdge {
722                        from_lane: 1,
723                        to_lane: 1,
724                        is_merge: None,
725                    },
726                ]
727            } else {
728                vec![GitGraphEdge {
729                    from_lane: 0,
730                    to_lane: 0,
731                    is_merge: None,
732                }]
733            };
734
735            Some(GitLogCommit {
736                sha: sha.to_string(),
737                short_sha: short_sha.to_string(),
738                message: summary.to_string(),
739                summary: summary.to_string(),
740                author_name: author_name.to_string(),
741                author_email: author_email.to_string(),
742                authored_at: authored_at.to_string(),
743                parents,
744                refs: ref_map.get(sha).cloned().unwrap_or_default(),
745                lane: Some(lane),
746                graph_edges: Some(graph_edges),
747            })
748        })
749        .collect()
750}
751
752fn git_log_matches_search(commit: &GitLogCommit, query: &str) -> bool {
753    let query = query.trim().to_lowercase();
754    if query.is_empty() {
755        return true;
756    }
757
758    [
759        commit.sha.as_str(),
760        commit.short_sha.as_str(),
761        commit.summary.as_str(),
762        commit.author_name.as_str(),
763        commit.author_email.as_str(),
764    ]
765    .iter()
766    .any(|value| value.to_lowercase().contains(&query))
767}
768
769fn git_commit_file_status(code: &str) -> CommitFileChangeKind {
770    match code.chars().next().unwrap_or('M') {
771        'A' => CommitFileChangeKind::Added,
772        'D' => CommitFileChangeKind::Deleted,
773        'R' => CommitFileChangeKind::Renamed,
774        'C' => CommitFileChangeKind::Copied,
775        _ => CommitFileChangeKind::Modified,
776    }
777}
778
779pub fn list_git_refs(repo_path: &str) -> Result<GitRefsResult, String> {
780    let current_branch = get_current_branch(repo_path);
781
782    let local_output = Command::new("git")
783        .args([
784            "for-each-ref",
785            "--format=%(refname:short)%09%(objectname)",
786            "refs/heads/",
787        ])
788        .current_dir(repo_path)
789        .output()
790        .map_err(|error| error.to_string())?;
791
792    if !local_output.status.success() {
793        return Err(String::from_utf8_lossy(&local_output.stderr)
794            .trim()
795            .to_string());
796    }
797
798    let local: Vec<GitLogRef> = String::from_utf8_lossy(&local_output.stdout)
799        .lines()
800        .filter_map(|line| {
801            let (name, sha) = line.split_once('\t')?;
802            let name = name.trim();
803            let sha = sha.trim();
804            if name.is_empty() || sha.is_empty() {
805                return None;
806            }
807
808            Some(GitLogRef {
809                name: name.to_string(),
810                remote: None,
811                kind: GitRefKind::Local,
812                commit_sha: sha.to_string(),
813                is_current: Some(current_branch.as_deref() == Some(name)),
814            })
815        })
816        .collect();
817
818    let head = local
819        .iter()
820        .find(|git_ref| git_ref.is_current == Some(true))
821        .map(|git_ref| {
822            let mut head_ref = git_ref.clone();
823            head_ref.kind = GitRefKind::Head;
824            head_ref.is_current = Some(true);
825            head_ref
826        });
827
828    let remote_output = Command::new("git")
829        .args([
830            "for-each-ref",
831            "--format=%(refname:short)%09%(objectname)",
832            "refs/remotes/",
833        ])
834        .current_dir(repo_path)
835        .output()
836        .map_err(|error| error.to_string())?;
837
838    let remote = if remote_output.status.success() {
839        String::from_utf8_lossy(&remote_output.stdout)
840            .lines()
841            .filter_map(|line| {
842                let (full_name, sha) = line.split_once('\t')?;
843                let full_name = full_name.trim();
844                let sha = sha.trim();
845                if full_name.is_empty()
846                    || sha.is_empty()
847                    || full_name.ends_with("/HEAD")
848                    || !full_name.contains('/')
849                {
850                    return None;
851                }
852
853                let (remote, name) = full_name.split_once('/')?;
854                if remote.is_empty() || name.is_empty() {
855                    return None;
856                }
857
858                Some(GitLogRef {
859                    name: name.to_string(),
860                    remote: Some(remote.to_string()),
861                    kind: GitRefKind::Remote,
862                    commit_sha: sha.to_string(),
863                    is_current: None,
864                })
865            })
866            .collect()
867    } else {
868        Vec::new()
869    };
870
871    let tag_output = Command::new("git")
872        .args([
873            "for-each-ref",
874            "--format=%(refname:short)%09%(*objectname)%09%(objectname)",
875            "refs/tags/",
876        ])
877        .current_dir(repo_path)
878        .output()
879        .map_err(|error| error.to_string())?;
880
881    let tags = if tag_output.status.success() {
882        String::from_utf8_lossy(&tag_output.stdout)
883            .lines()
884            .filter_map(|line| {
885                let mut parts = line.split('\t');
886                let name = parts.next()?.trim();
887                let deref_sha = parts.next().unwrap_or_default().trim();
888                let sha = if deref_sha.is_empty() {
889                    parts.next().unwrap_or_default().trim()
890                } else {
891                    deref_sha
892                };
893                if name.is_empty() || sha.is_empty() {
894                    return None;
895                }
896
897                Some(GitLogRef {
898                    name: name.to_string(),
899                    remote: None,
900                    kind: GitRefKind::Tag,
901                    commit_sha: sha.to_string(),
902                    is_current: None,
903                })
904            })
905            .collect()
906    } else {
907        Vec::new()
908    };
909
910    Ok(GitRefsResult {
911        head,
912        local,
913        remote,
914        tags,
915    })
916}
917
918pub fn get_git_log_page(
919    repo_path: &str,
920    branches: Option<&[String]>,
921    search: Option<&str>,
922    limit: Option<usize>,
923    skip: Option<usize>,
924) -> Result<GitLogPage, String> {
925    let refs = list_git_refs(repo_path)?;
926    let ref_map = git_refs_map(&refs);
927    let limit = limit.unwrap_or(40).min(200);
928    let skip = skip.unwrap_or(0);
929    let search = search.unwrap_or("").trim().to_string();
930    let branch_filters: Vec<String> = branches
931        .unwrap_or(&[])
932        .iter()
933        .map(|branch| branch.trim())
934        .filter(|branch| !branch.is_empty())
935        .map(str::to_string)
936        .collect();
937    let has_branch_filters = !branch_filters.is_empty();
938    let should_scan_for_search = !search.is_empty();
939
940    let mut command = Command::new("git");
941    command.args([
942        "--no-pager",
943        "log",
944        "--date-order",
945        "--format=%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P%x00",
946    ]);
947
948    if should_scan_for_search {
949        command.arg(format!("--max-count={GIT_LOG_SEARCH_SCAN_LIMIT}"));
950    } else {
951        command.arg(format!("--skip={skip}"));
952        command.arg(format!("--max-count={}", limit + 1));
953    }
954
955    if has_branch_filters {
956        for branch in &branch_filters {
957            command.arg(branch);
958        }
959    } else {
960        command.arg("--all");
961    }
962
963    let output = command
964        .current_dir(repo_path)
965        .output()
966        .map_err(|error| error.to_string())?;
967
968    if !output.status.success() {
969        return Ok(GitLogPage {
970            commits: Vec::new(),
971            total: 0,
972            has_more: false,
973        });
974    }
975
976    let parsed_commits = parse_git_log_records(&String::from_utf8_lossy(&output.stdout), &ref_map);
977
978    if should_scan_for_search {
979        let filtered_commits: Vec<GitLogCommit> = parsed_commits
980            .into_iter()
981            .filter(|commit| git_log_matches_search(commit, &search))
982            .collect();
983        let commits = filtered_commits
984            .iter()
985            .skip(skip)
986            .take(limit)
987            .cloned()
988            .collect::<Vec<_>>();
989        let total = filtered_commits.len();
990
991        return Ok(GitLogPage {
992            commits,
993            total,
994            has_more: skip + limit < total,
995        });
996    }
997
998    let total = {
999        let mut count_command = Command::new("git");
1000        count_command.args(["rev-list", "--count"]);
1001        if has_branch_filters {
1002            for branch in &branch_filters {
1003                count_command.arg(branch);
1004            }
1005        } else {
1006            count_command.arg("--all");
1007        }
1008
1009        match count_command.current_dir(repo_path).output() {
1010            Ok(count_output) if count_output.status.success() => {
1011                String::from_utf8_lossy(&count_output.stdout)
1012                    .trim()
1013                    .parse::<usize>()
1014                    .unwrap_or(parsed_commits.len())
1015            }
1016            _ => parsed_commits.len(),
1017        }
1018    };
1019
1020    let has_more = parsed_commits.len() > limit;
1021    let commits = parsed_commits.into_iter().take(limit).collect();
1022
1023    Ok(GitLogPage {
1024        commits,
1025        total,
1026        has_more,
1027    })
1028}
1029
1030pub fn get_git_commit_detail(repo_path: &str, sha: &str) -> Result<GitCommitDetail, String> {
1031    let refs = list_git_refs(repo_path)?;
1032    let ref_map = git_refs_map(&refs);
1033
1034    let metadata_output = Command::new("git")
1035        .args([
1036            "--no-pager",
1037            "show",
1038            "-s",
1039            "--format=%H%x00%h%x00%s%x00%B%x00%an%x00%ae%x00%aI%x00%P",
1040            sha,
1041        ])
1042        .current_dir(repo_path)
1043        .output()
1044        .map_err(|error| error.to_string())?;
1045
1046    if !metadata_output.status.success() {
1047        return Err(String::from_utf8_lossy(&metadata_output.stderr)
1048            .trim()
1049            .to_string());
1050    }
1051
1052    let metadata = String::from_utf8_lossy(&metadata_output.stdout);
1053    let parts: Vec<&str> = metadata.split('\0').collect();
1054    if parts.len() < 8 {
1055        return Err("Failed to parse commit metadata".to_string());
1056    }
1057
1058    let sha = parts[0].trim().to_string();
1059    let short_sha = parts[1].trim().to_string();
1060    let summary = parts[2].trim().to_string();
1061    let message = parts[3].trim().to_string();
1062    let author_name = parts[4].trim().to_string();
1063    let author_email = parts[5].trim().to_string();
1064    let authored_at = parts[6].trim().to_string();
1065    let parents = parts[7]
1066        .split_whitespace()
1067        .map(str::to_string)
1068        .collect::<Vec<_>>();
1069
1070    let name_status_output = Command::new("git")
1071        .args(["show", "--format=", "--name-status", sha.as_str()])
1072        .current_dir(repo_path)
1073        .output()
1074        .map_err(|error| error.to_string())?;
1075
1076    if !name_status_output.status.success() {
1077        return Err(String::from_utf8_lossy(&name_status_output.stderr)
1078            .trim()
1079            .to_string());
1080    }
1081
1082    let numstat_output = Command::new("git")
1083        .args(["show", "--format=", "--numstat", sha.as_str()])
1084        .current_dir(repo_path)
1085        .output()
1086        .map_err(|error| error.to_string())?;
1087
1088    if !numstat_output.status.success() {
1089        return Err(String::from_utf8_lossy(&numstat_output.stderr)
1090            .trim()
1091            .to_string());
1092    }
1093
1094    let mut file_stats = HashMap::new();
1095    for line in String::from_utf8_lossy(&numstat_output.stdout).lines() {
1096        let parts: Vec<&str> = line.split('\t').collect();
1097        if parts.len() < 3 {
1098            continue;
1099        }
1100
1101        let additions = if parts[0] == "-" {
1102            0
1103        } else {
1104            parts[0].parse::<i32>().unwrap_or(0)
1105        };
1106        let deletions = if parts[1] == "-" {
1107            0
1108        } else {
1109            parts[1].parse::<i32>().unwrap_or(0)
1110        };
1111        file_stats.insert(parts[2].to_string(), (additions, deletions));
1112    }
1113
1114    let files = String::from_utf8_lossy(&name_status_output.stdout)
1115        .lines()
1116        .filter_map(|line| {
1117            let parts: Vec<&str> = line.split('\t').collect();
1118            if parts.len() < 2 {
1119                return None;
1120            }
1121
1122            let status = git_commit_file_status(parts[0]);
1123            let (path, previous_path) = if matches!(
1124                status,
1125                CommitFileChangeKind::Renamed | CommitFileChangeKind::Copied
1126            ) && parts.len() >= 3
1127            {
1128                (parts[2].to_string(), Some(parts[1].to_string()))
1129            } else {
1130                (parts[1].to_string(), None)
1131            };
1132
1133            let key = previous_path.clone().unwrap_or_else(|| path.clone());
1134            let (additions, deletions) = file_stats.get(&key).copied().unwrap_or_default();
1135
1136            Some(GitCommitFileChange {
1137                path,
1138                previous_path,
1139                status,
1140                additions,
1141                deletions,
1142            })
1143        })
1144        .collect();
1145
1146    let commit = GitLogCommit {
1147        sha: sha.clone(),
1148        short_sha,
1149        message,
1150        summary,
1151        author_name,
1152        author_email,
1153        authored_at,
1154        parents: parents.clone(),
1155        refs: ref_map.get(&sha).cloned().unwrap_or_default(),
1156        lane: Some(if parents.len() > 1 { 1 } else { 0 }),
1157        graph_edges: Some(if parents.len() > 1 {
1158            vec![
1159                GitGraphEdge {
1160                    from_lane: 1,
1161                    to_lane: 0,
1162                    is_merge: Some(true),
1163                },
1164                GitGraphEdge {
1165                    from_lane: 1,
1166                    to_lane: 1,
1167                    is_merge: None,
1168                },
1169            ]
1170        } else {
1171            vec![GitGraphEdge {
1172                from_lane: 0,
1173                to_lane: 0,
1174                is_merge: None,
1175            }]
1176        }),
1177    };
1178
1179    Ok(GitCommitDetail {
1180        commit,
1181        files,
1182        patch: None,
1183    })
1184}
1185
1186/// Get commit history from current branch
1187pub fn get_commit_list(
1188    repo_path: &str,
1189    limit: Option<usize>,
1190    since: Option<&str>,
1191) -> Result<Vec<CommitInfo>, String> {
1192    let limit_str = limit.unwrap_or(20).to_string();
1193    let mut args = vec![
1194        "log",
1195        "--format=%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b%x1f%P%x1d",
1196        "--numstat",
1197        "-n",
1198        &limit_str,
1199    ];
1200
1201    let since_str;
1202    if let Some(since_value) = since {
1203        since_str = format!("--since={}", since_value);
1204        args.push(&since_str);
1205    }
1206
1207    let output = Command::new("git")
1208        .args(&args)
1209        .current_dir(repo_path)
1210        .output()
1211        .map_err(|e| e.to_string())?;
1212
1213    if !output.status.success() {
1214        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1215    }
1216
1217    let mut commits = Vec::new();
1218    for record in String::from_utf8_lossy(&output.stdout)
1219        .split('\u{001e}')
1220        .map(str::trim)
1221        .filter(|record| !record.is_empty())
1222    {
1223        let Some((header, stats_section)) = record.split_once('\u{001d}') else {
1224            continue;
1225        };
1226
1227        let parts: Vec<&str> = header.split('\u{001f}').collect();
1228        if parts.len() < 8 {
1229            continue;
1230        }
1231
1232        let sha = parts[0].trim().to_string();
1233        let short_sha = parts[1].trim().to_string();
1234        let author_name = parts[2].trim().to_string();
1235        let author_email = parts[3].trim().to_string();
1236        let authored_at = parts[4].trim().to_string();
1237        let subject = parts[5].trim().to_string();
1238        let body = parts[6].trim().to_string();
1239        let parents_str = parts[7].trim();
1240        let parents: Vec<String> = if parents_str.is_empty() {
1241            Vec::new()
1242        } else {
1243            parents_str
1244                .split_whitespace()
1245                .map(|value| value.to_string())
1246                .collect()
1247        };
1248
1249        let message = if body.is_empty() {
1250            subject.clone()
1251        } else {
1252            format!("{}\n\n{}", subject, body)
1253        };
1254
1255        let mut additions = 0;
1256        let mut deletions = 0;
1257
1258        for stat_line in stats_section
1259            .lines()
1260            .map(str::trim)
1261            .filter(|line| !line.is_empty())
1262        {
1263            let stat_parts: Vec<&str> = stat_line.split('\t').collect();
1264            if stat_parts.len() >= 2 {
1265                if stat_parts[0] != "-" {
1266                    additions += stat_parts[0].parse::<i32>().unwrap_or(0);
1267                }
1268                if stat_parts[1] != "-" {
1269                    deletions += stat_parts[1].parse::<i32>().unwrap_or(0);
1270                }
1271            }
1272        }
1273
1274        commits.push(CommitInfo {
1275            sha,
1276            short_sha,
1277            message,
1278            summary: subject,
1279            author_name,
1280            author_email,
1281            authored_at,
1282            additions,
1283            deletions,
1284            parents,
1285        });
1286    }
1287
1288    Ok(commits)
1289}
1290
1291#[derive(Debug, Clone, Serialize, Deserialize)]
1292#[serde(rename_all = "camelCase")]
1293pub struct RepoStatus {
1294    pub clean: bool,
1295    pub ahead: i32,
1296    pub behind: i32,
1297    pub modified: i32,
1298    pub untracked: i32,
1299}
1300
1301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1302#[serde(rename_all = "camelCase")]
1303pub enum FileChangeStatus {
1304    Modified,
1305    Added,
1306    Deleted,
1307    Renamed,
1308    Copied,
1309    Untracked,
1310    Typechange,
1311    Conflicted,
1312}
1313
1314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1315#[serde(rename_all = "camelCase")]
1316pub struct GitFileChange {
1317    pub path: String,
1318    pub status: FileChangeStatus,
1319    #[serde(skip_serializing_if = "Option::is_none")]
1320    pub previous_path: Option<String>,
1321}
1322
1323#[derive(Debug, Clone, Serialize, Deserialize)]
1324#[serde(rename_all = "camelCase")]
1325pub struct RepoChanges {
1326    pub branch: String,
1327    pub status: RepoStatus,
1328    pub files: Vec<GitFileChange>,
1329}
1330
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1332#[serde(rename_all = "camelCase")]
1333pub struct RepoFileDiff {
1334    pub path: String,
1335    pub status: FileChangeStatus,
1336    #[serde(skip_serializing_if = "Option::is_none")]
1337    pub previous_path: Option<String>,
1338    pub patch: String,
1339    pub additions: i32,
1340    pub deletions: i32,
1341}
1342
1343#[derive(Debug, Clone, Serialize, Deserialize)]
1344#[serde(rename_all = "camelCase")]
1345pub struct RepoCommitDiff {
1346    pub sha: String,
1347    pub short_sha: String,
1348    pub summary: String,
1349    pub author_name: String,
1350    pub authored_at: String,
1351    pub patch: String,
1352    pub additions: i32,
1353    pub deletions: i32,
1354}
1355
1356#[derive(Debug, Clone, Serialize, Deserialize)]
1357#[serde(rename_all = "camelCase")]
1358pub struct HistoricalRelatedFile {
1359    pub path: String,
1360    pub score: f64,
1361    pub source_files: Vec<String>,
1362    pub related_commits: Vec<String>,
1363}
1364
1365#[derive(Default)]
1366struct HistoricalCandidateAggregate {
1367    hits: u32,
1368    source_files: BTreeSet<String>,
1369    related_commits: BTreeSet<String>,
1370}
1371
1372#[derive(Debug, Clone)]
1373struct BlameChunk {
1374    commit: String,
1375    start: u32,
1376    end: u32,
1377}
1378
1379pub fn get_repo_status(repo_path: &str) -> RepoStatus {
1380    let mut status = RepoStatus {
1381        clean: true,
1382        ahead: 0,
1383        behind: 0,
1384        modified: 0,
1385        untracked: 0,
1386    };
1387
1388    if let Ok(o) = Command::new("git")
1389        .args(["status", "--porcelain", "-uall"])
1390        .current_dir(repo_path)
1391        .output()
1392    {
1393        if o.status.success() {
1394            let text = String::from_utf8_lossy(&o.stdout);
1395            let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
1396            status.modified = lines.iter().filter(|l| !l.starts_with("??")).count() as i32;
1397            status.untracked = lines.iter().filter(|l| l.starts_with("??")).count() as i32;
1398            status.clean = lines.is_empty();
1399        }
1400    }
1401
1402    if let Ok(o) = Command::new("git")
1403        .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
1404        .current_dir(repo_path)
1405        .output()
1406    {
1407        if o.status.success() {
1408            let text = String::from_utf8_lossy(&o.stdout);
1409            let parts: Vec<&str> = text.split_whitespace().collect();
1410            if parts.len() == 2 {
1411                status.ahead = parts[0].parse().unwrap_or(0);
1412                status.behind = parts[1].parse().unwrap_or(0);
1413            }
1414        }
1415    }
1416
1417    status
1418}
1419
1420fn map_porcelain_status(code: &str) -> FileChangeStatus {
1421    if code == "??" {
1422        return FileChangeStatus::Untracked;
1423    }
1424
1425    let mut chars = code.chars();
1426    let index_status = chars.next().unwrap_or(' ');
1427    let worktree_status = chars.next().unwrap_or(' ');
1428
1429    if index_status == 'U' || worktree_status == 'U' || code == "AA" || code == "DD" {
1430        return FileChangeStatus::Conflicted;
1431    }
1432    if index_status == 'R' || worktree_status == 'R' {
1433        return FileChangeStatus::Renamed;
1434    }
1435    if index_status == 'C' || worktree_status == 'C' {
1436        return FileChangeStatus::Copied;
1437    }
1438    if index_status == 'A' || worktree_status == 'A' {
1439        return FileChangeStatus::Added;
1440    }
1441    if index_status == 'D' || worktree_status == 'D' {
1442        return FileChangeStatus::Deleted;
1443    }
1444    if index_status == 'T' || worktree_status == 'T' {
1445        return FileChangeStatus::Typechange;
1446    }
1447    FileChangeStatus::Modified
1448}
1449
1450pub fn parse_git_status_porcelain(output: &str) -> Vec<GitFileChange> {
1451    output
1452        .lines()
1453        .filter(|line| !line.trim().is_empty())
1454        .filter_map(|line| {
1455            if line.len() < 3 {
1456                return None;
1457            }
1458
1459            let code = &line[0..2];
1460            if code == "!!" {
1461                return None;
1462            }
1463
1464            let raw_path = line[3..].trim().to_string();
1465            let status = map_porcelain_status(code);
1466
1467            if matches!(status, FileChangeStatus::Renamed | FileChangeStatus::Copied)
1468                && raw_path.contains(" -> ")
1469            {
1470                let parts: Vec<&str> = raw_path.splitn(2, " -> ").collect();
1471                if parts.len() == 2 {
1472                    return Some(GitFileChange {
1473                        path: parts[1].to_string(),
1474                        previous_path: Some(parts[0].to_string()),
1475                        status,
1476                    });
1477                }
1478            }
1479
1480            Some(GitFileChange {
1481                path: raw_path,
1482                previous_path: None,
1483                status,
1484            })
1485        })
1486        .collect()
1487}
1488
1489pub fn get_repo_changes(repo_path: &str) -> RepoChanges {
1490    let branch = get_current_branch(repo_path).unwrap_or_else(|| "unknown".into());
1491    let status = get_repo_status(repo_path);
1492    let files = Command::new("git")
1493        .args(["status", "--porcelain", "-uall"])
1494        .current_dir(repo_path)
1495        .output()
1496        .ok()
1497        .filter(|o| o.status.success())
1498        .map(|o| parse_git_status_porcelain(&String::from_utf8_lossy(&o.stdout)))
1499        .unwrap_or_default();
1500
1501    RepoChanges {
1502        branch,
1503        status,
1504        files,
1505    }
1506}
1507
1508fn git_output_in_repo(repo_path: &str, args: &[&str]) -> Option<String> {
1509    Command::new("git")
1510        .args(args)
1511        .current_dir(repo_path)
1512        .output()
1513        .ok()
1514        .filter(|output| output.status.success())
1515        .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1516}
1517
1518fn count_diff_patch_lines(patch: &str) -> (i32, i32) {
1519    let mut additions = 0;
1520    let mut deletions = 0;
1521
1522    for line in patch.lines() {
1523        if line.starts_with("+++ ") || line.starts_with("--- ") {
1524            continue;
1525        }
1526        if line.starts_with('+') {
1527            additions += 1;
1528        } else if line.starts_with('-') {
1529            deletions += 1;
1530        }
1531    }
1532
1533    (additions, deletions)
1534}
1535
1536fn build_synthetic_added_diff(repo_path: &str, file: &GitFileChange) -> String {
1537    let file_path = Path::new(repo_path).join(&file.path);
1538    let content = std::fs::read_to_string(&file_path).unwrap_or_default();
1539    let additions = content
1540        .lines()
1541        .map(|line| format!("+{}", line))
1542        .collect::<Vec<_>>()
1543        .join("\n");
1544
1545    format!(
1546        "diff --git a/{path} b/{path}\nnew file mode 100644\n--- /dev/null\n+++ b/{path}\n@@ -0,0 +1,{count} @@\n{body}",
1547        path = file.path,
1548        count = content.lines().count(),
1549        body = additions
1550    )
1551}
1552
1553fn build_synthetic_rename_diff(file: &GitFileChange) -> String {
1554    let previous_path = file.previous_path.clone().unwrap_or_default();
1555    format!(
1556        "diff --git a/{previous_path} b/{path}\nsimilarity index 100%\nrename from {previous_path}\nrename to {path}\n",
1557        previous_path = previous_path,
1558        path = file.path
1559    )
1560}
1561
1562pub fn get_repo_file_diff(repo_path: &str, file: &GitFileChange) -> RepoFileDiff {
1563    let patch = [
1564        vec![
1565            "--no-pager",
1566            "diff",
1567            "--no-ext-diff",
1568            "--find-renames",
1569            "--find-copies",
1570            "--",
1571            file.path.as_str(),
1572        ],
1573        vec![
1574            "--no-pager",
1575            "diff",
1576            "--no-ext-diff",
1577            "--find-renames",
1578            "--find-copies",
1579            "--cached",
1580            "--",
1581            file.path.as_str(),
1582        ],
1583        vec![
1584            "--no-pager",
1585            "diff",
1586            "--no-ext-diff",
1587            "--find-renames",
1588            "--find-copies",
1589            "HEAD",
1590            "--",
1591            file.path.as_str(),
1592        ],
1593    ]
1594    .iter()
1595    .filter_map(|args| git_output_in_repo(repo_path, args))
1596    .find(|candidate| !candidate.trim().is_empty())
1597    .unwrap_or_else(|| {
1598        if matches!(
1599            file.status,
1600            FileChangeStatus::Untracked | FileChangeStatus::Added
1601        ) {
1602            build_synthetic_added_diff(repo_path, file)
1603        } else if matches!(file.status, FileChangeStatus::Renamed) && file.previous_path.is_some() {
1604            build_synthetic_rename_diff(file)
1605        } else {
1606            String::new()
1607        }
1608    });
1609
1610    let (additions, deletions) = count_diff_patch_lines(&patch);
1611    RepoFileDiff {
1612        path: file.path.clone(),
1613        status: file.status.clone(),
1614        previous_path: file.previous_path.clone(),
1615        patch,
1616        additions,
1617        deletions,
1618    }
1619}
1620
1621pub fn get_repo_commit_diff(repo_path: &str, sha: &str) -> RepoCommitDiff {
1622    let summary =
1623        git_output_in_repo(repo_path, &["show", "-s", "--format=%s", sha]).unwrap_or_default();
1624    let short_sha =
1625        git_output_in_repo(repo_path, &["rev-parse", "--short", sha]).unwrap_or_default();
1626    let author_name =
1627        git_output_in_repo(repo_path, &["show", "-s", "--format=%an", sha]).unwrap_or_default();
1628    let authored_at =
1629        git_output_in_repo(repo_path, &["show", "-s", "--format=%aI", sha]).unwrap_or_default();
1630    let patch = git_output_in_repo(
1631        repo_path,
1632        &[
1633            "--no-pager",
1634            "show",
1635            "--no-ext-diff",
1636            "--find-renames",
1637            "--find-copies",
1638            "--format=medium",
1639            "--unified=3",
1640            sha,
1641        ],
1642    )
1643    .unwrap_or_default();
1644    let (additions, deletions) = count_diff_patch_lines(&patch);
1645
1646    RepoCommitDiff {
1647        sha: sha.to_string(),
1648        short_sha: short_sha.trim().to_string(),
1649        summary: summary.trim().to_string(),
1650        author_name: author_name.trim().to_string(),
1651        authored_at: authored_at.trim().to_string(),
1652        patch,
1653        additions,
1654        deletions,
1655    }
1656}
1657
1658fn git_output_at_path(repo_root: &Path, args: &[&str]) -> Result<String, String> {
1659    let output = Command::new("git")
1660        .args(args)
1661        .current_dir(repo_root)
1662        .output()
1663        .map_err(|err| format!("Failed to run git {}: {}", args.join(" "), err))?;
1664
1665    if output.status.success() {
1666        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
1667    } else {
1668        Err(format!(
1669            "git {} failed: {}",
1670            args.join(" "),
1671            String::from_utf8_lossy(&output.stderr).trim()
1672        ))
1673    }
1674}
1675
1676/// Build historical co-change context for a review diff range.
1677///
1678/// The output is intentionally compact and best-effort friendly for review payloads.
1679pub fn compute_historical_related_files(
1680    repo_root: &Path,
1681    diff_range: &str,
1682    head: &str,
1683    max_results: usize,
1684) -> Result<Vec<HistoricalRelatedFile>, String> {
1685    let changed_files: Vec<String> =
1686        git_output_at_path(repo_root, &["diff", "--name-only", diff_range])?
1687            .lines()
1688            .map(str::trim)
1689            .filter(|line| !line.is_empty())
1690            .map(str::to_string)
1691            .collect();
1692
1693    if changed_files.is_empty() {
1694        return Ok(Vec::new());
1695    }
1696
1697    let source_files: Vec<String> = changed_files.into_iter().take(8).collect();
1698    let changed_file_set: BTreeSet<String> = source_files.iter().cloned().collect();
1699    let mut candidate_map: HashMap<String, HistoricalCandidateAggregate> = HashMap::new();
1700    let mut blame_cache: HashMap<String, Vec<BlameChunk>> = HashMap::new();
1701    let mut commit_paths_cache: HashMap<String, Vec<String>> = HashMap::new();
1702
1703    for source_file in &source_files {
1704        if !file_exists_at_revision(repo_root, head, source_file) {
1705            continue;
1706        }
1707
1708        let line_samples = collect_interesting_lines(repo_root, diff_range, source_file)?;
1709        if line_samples.is_empty() {
1710            continue;
1711        }
1712
1713        let blame_chunks = load_blame_chunks(repo_root, head, source_file, &mut blame_cache)?;
1714        if blame_chunks.is_empty() {
1715            continue;
1716        }
1717
1718        let mut interesting_commits: Vec<(String, u32)> =
1719            collect_interesting_commits(&blame_chunks, &line_samples)
1720                .into_iter()
1721                .collect();
1722        interesting_commits
1723            .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
1724        interesting_commits.truncate(8);
1725
1726        for (commit_sha, hits) in interesting_commits {
1727            let changed_in_commit =
1728                load_changed_files_for_commit(repo_root, &commit_sha, &mut commit_paths_cache)?;
1729
1730            for candidate_path in changed_in_commit {
1731                if candidate_path.is_empty()
1732                    || candidate_path == *source_file
1733                    || changed_file_set.contains(&candidate_path)
1734                {
1735                    continue;
1736                }
1737
1738                let entry = candidate_map.entry(candidate_path).or_default();
1739                entry.hits = entry.hits.saturating_add(hits);
1740                entry.source_files.insert(source_file.clone());
1741                entry.related_commits.insert(commit_sha.clone());
1742            }
1743        }
1744    }
1745
1746    if candidate_map.is_empty() {
1747        return Ok(Vec::new());
1748    }
1749
1750    let mut related_files: Vec<HistoricalRelatedFile> = candidate_map
1751        .into_iter()
1752        .map(|(path, aggregate)| HistoricalRelatedFile {
1753            path,
1754            score: aggregate.hits as f64,
1755            source_files: aggregate.source_files.into_iter().collect(),
1756            related_commits: aggregate.related_commits.into_iter().collect(),
1757        })
1758        .collect();
1759
1760    related_files.sort_by(|left, right| {
1761        right
1762            .score
1763            .partial_cmp(&left.score)
1764            .unwrap_or(Ordering::Equal)
1765            .then_with(|| right.source_files.len().cmp(&left.source_files.len()))
1766            .then_with(|| left.path.cmp(&right.path))
1767    });
1768
1769    if max_results > 0 && related_files.len() > max_results {
1770        related_files.truncate(max_results);
1771    }
1772
1773    Ok(related_files)
1774}
1775
1776fn file_exists_at_revision(repo_root: &Path, revision: &str, file_path: &str) -> bool {
1777    Command::new("git")
1778        .args(["cat-file", "-e", &format!("{}:{}", revision, file_path)])
1779        .current_dir(repo_root)
1780        .output()
1781        .map(|output| output.status.success())
1782        .unwrap_or(false)
1783}
1784
1785fn collect_interesting_lines(
1786    repo_root: &Path,
1787    diff_range: &str,
1788    file_path: &str,
1789) -> Result<Vec<u32>, String> {
1790    let raw_diff = git_output_at_path(
1791        repo_root,
1792        &["diff", "--unified=0", diff_range, "--", file_path],
1793    )?;
1794    if raw_diff.is_empty() {
1795        return Ok(Vec::new());
1796    }
1797
1798    let hunk_pattern = Regex::new(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
1799        .map_err(|err| format!("Failed to compile diff hunk regex: {}", err))?;
1800    let mut interesting_lines = BTreeSet::new();
1801
1802    for line in raw_diff.lines() {
1803        let Some(captures) = hunk_pattern.captures(line) else {
1804            continue;
1805        };
1806
1807        let start = captures
1808            .get(1)
1809            .and_then(|value| value.as_str().parse::<u32>().ok())
1810            .unwrap_or(0);
1811        let count = captures
1812            .get(2)
1813            .and_then(|value| value.as_str().parse::<u32>().ok())
1814            .unwrap_or(1);
1815        let span = if count == 0 { 1 } else { count };
1816        let end = start.saturating_add(span.saturating_sub(1));
1817
1818        for line_number in [start.saturating_sub(1), start, end, end.saturating_add(1)] {
1819            if line_number > 0 {
1820                interesting_lines.insert(line_number);
1821            }
1822        }
1823    }
1824
1825    Ok(interesting_lines.into_iter().collect())
1826}
1827
1828fn load_blame_chunks(
1829    repo_root: &Path,
1830    revision: &str,
1831    file_path: &str,
1832    cache: &mut HashMap<String, Vec<BlameChunk>>,
1833) -> Result<Vec<BlameChunk>, String> {
1834    let cache_key = format!("{}:{}", revision, file_path);
1835    if let Some(chunks) = cache.get(&cache_key) {
1836        return Ok(chunks.clone());
1837    }
1838
1839    let raw_blame = match git_output_at_path(
1840        repo_root,
1841        &["blame", "--incremental", revision, "--", file_path],
1842    ) {
1843        Ok(output) => output,
1844        Err(_) => {
1845            cache.insert(cache_key, Vec::new());
1846            return Ok(Vec::new());
1847        }
1848    };
1849
1850    let header_pattern = Regex::new(r"^([0-9a-f]{40}) \d+ (\d+) (\d+)$")
1851        .map_err(|err| format!("Failed to compile blame regex: {}", err))?;
1852    let mut chunks = Vec::new();
1853    let mut current_chunk: Option<BlameChunk> = None;
1854
1855    for line in raw_blame.lines() {
1856        if let Some(captures) = header_pattern.captures(line) {
1857            let commit = captures
1858                .get(1)
1859                .map(|value| value.as_str().to_string())
1860                .unwrap_or_default();
1861            let start = captures
1862                .get(2)
1863                .and_then(|value| value.as_str().parse::<u32>().ok())
1864                .unwrap_or(0);
1865            let num_lines = captures
1866                .get(3)
1867                .and_then(|value| value.as_str().parse::<u32>().ok())
1868                .unwrap_or(0);
1869            current_chunk = Some(BlameChunk {
1870                commit,
1871                start,
1872                end: start.saturating_add(num_lines),
1873            });
1874            continue;
1875        }
1876
1877        if line.starts_with("filename ") {
1878            if let Some(chunk) = current_chunk.take() {
1879                chunks.push(chunk);
1880            }
1881        }
1882    }
1883
1884    chunks.sort_by(|left, right| left.start.cmp(&right.start));
1885    cache.insert(cache_key, chunks.clone());
1886    Ok(chunks)
1887}
1888
1889fn collect_interesting_commits(
1890    blame_chunks: &[BlameChunk],
1891    line_numbers: &[u32],
1892) -> HashMap<String, u32> {
1893    let mut commit_hits = HashMap::new();
1894
1895    for line_number in line_numbers {
1896        if let Some(chunk) = blame_chunks
1897            .iter()
1898            .find(|candidate| *line_number >= candidate.start && *line_number < candidate.end)
1899        {
1900            *commit_hits.entry(chunk.commit.clone()).or_insert(0) += 1;
1901        }
1902    }
1903
1904    commit_hits
1905}
1906
1907fn load_changed_files_for_commit(
1908    repo_root: &Path,
1909    commit: &str,
1910    cache: &mut HashMap<String, Vec<String>>,
1911) -> Result<Vec<String>, String> {
1912    if let Some(files) = cache.get(commit) {
1913        return Ok(files.clone());
1914    }
1915
1916    let raw_files = match git_output_at_path(
1917        repo_root,
1918        &[
1919            "diff-tree",
1920            "--root",
1921            "--no-commit-id",
1922            "--name-only",
1923            "-r",
1924            "-m",
1925            commit,
1926        ],
1927    ) {
1928        Ok(output) => output,
1929        Err(_) => {
1930            cache.insert(commit.to_string(), Vec::new());
1931            return Ok(Vec::new());
1932        }
1933    };
1934
1935    let files: Vec<String> = raw_files
1936        .lines()
1937        .map(str::trim)
1938        .filter(|line| !line.is_empty())
1939        .map(str::to_string)
1940        .collect::<BTreeSet<_>>()
1941        .into_iter()
1942        .collect();
1943    cache.insert(commit.to_string(), files.clone());
1944    Ok(files)
1945}
1946
1947#[derive(Debug, Clone, Serialize, Deserialize)]
1948#[serde(rename_all = "camelCase")]
1949pub struct ClonedRepoInfo {
1950    pub name: String,
1951    pub path: String,
1952    pub dir_name: String,
1953    pub branch: String,
1954    pub branches: Vec<String>,
1955    pub status: RepoStatus,
1956}
1957
1958/// List all cloned repos with branch and status info.
1959pub fn list_cloned_repos() -> Vec<ClonedRepoInfo> {
1960    let base_dir = get_clone_base_dir();
1961    if !base_dir.exists() {
1962        return vec![];
1963    }
1964
1965    let entries = match std::fs::read_dir(&base_dir) {
1966        Ok(e) => e,
1967        Err(_) => return vec![],
1968    };
1969
1970    entries
1971        .flatten()
1972        .filter(|e| e.path().is_dir())
1973        .map(|e| {
1974            let full_path = e.path();
1975            let dir_name = e.file_name().to_string_lossy().to_string();
1976            let path_str = full_path.to_string_lossy().to_string();
1977            let branch_info = get_branch_info(&path_str);
1978            let repo_status = get_repo_status(&path_str);
1979            ClonedRepoInfo {
1980                name: dir_name_to_repo(&dir_name),
1981                path: path_str,
1982                dir_name,
1983                branch: branch_info.current,
1984                branches: branch_info.branches,
1985                status: repo_status,
1986            }
1987        })
1988        .collect()
1989}
1990
1991/// Discover skills from a given path (looks for SKILL.md files in well-known subdirectories).
1992pub fn discover_skills_from_path(repo_path: &Path) -> Vec<DiscoveredSkill> {
1993    let dirs_to_check = [
1994        "skills",
1995        ".agents/skills",
1996        ".opencode/skills",
1997        ".claude/skills",
1998    ];
1999
2000    let mut result = Vec::new();
2001
2002    for dir in &dirs_to_check {
2003        let skill_dir = repo_path.join(dir);
2004        if skill_dir.is_dir() {
2005            scan_skill_dir(&skill_dir, &mut result);
2006        }
2007    }
2008
2009    // Also check root-level SKILL.md
2010    let root_skill = repo_path.join("SKILL.md");
2011    if root_skill.is_file() {
2012        if let Some(skill) = parse_discovered_skill(&root_skill) {
2013            result.push(skill);
2014        }
2015    }
2016
2017    result
2018}
2019
2020#[derive(Debug, Clone, Serialize, Deserialize)]
2021#[serde(rename_all = "camelCase")]
2022pub struct DiscoveredSkill {
2023    pub name: String,
2024    pub description: String,
2025    pub source: String,
2026    #[serde(skip_serializing_if = "Option::is_none")]
2027    pub license: Option<String>,
2028    #[serde(skip_serializing_if = "Option::is_none")]
2029    pub compatibility: Option<String>,
2030}
2031
2032fn scan_skill_dir(dir: &Path, out: &mut Vec<DiscoveredSkill>) {
2033    let entries = match std::fs::read_dir(dir) {
2034        Ok(e) => e,
2035        Err(_) => return,
2036    };
2037
2038    for entry in entries.flatten() {
2039        let path = entry.path();
2040        if path.is_dir() {
2041            let skill_file = path.join("SKILL.md");
2042            if skill_file.is_file() {
2043                if let Some(skill) = parse_discovered_skill(&skill_file) {
2044                    out.push(skill);
2045                }
2046            }
2047        }
2048    }
2049}
2050
2051/// YAML frontmatter structure for discovered skills.
2052#[derive(Debug, serde::Deserialize)]
2053struct SkillFrontmatter {
2054    name: String,
2055    description: String,
2056    #[serde(default)]
2057    license: Option<String>,
2058    #[serde(default)]
2059    compatibility: Option<String>,
2060}
2061
2062fn parse_discovered_skill(path: &Path) -> Option<DiscoveredSkill> {
2063    let content = std::fs::read_to_string(path).ok()?;
2064
2065    // Try YAML frontmatter first
2066    if let Some((fm_str, _body)) = extract_frontmatter_str(&content) {
2067        if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&fm_str) {
2068            return Some(DiscoveredSkill {
2069                name: fm.name,
2070                description: fm.description,
2071                source: path.to_string_lossy().to_string(),
2072                license: fm.license,
2073                compatibility: fm.compatibility,
2074            });
2075        }
2076    }
2077
2078    // Fallback: directory name + first paragraph
2079    let name = path
2080        .parent()
2081        .and_then(|p| p.file_name())
2082        .map(|n| n.to_string_lossy().to_string())
2083        .unwrap_or_else(|| "unknown".into());
2084
2085    let description = content
2086        .lines()
2087        .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
2088        .take_while(|l| !l.trim().is_empty())
2089        .collect::<Vec<_>>()
2090        .join(" ");
2091
2092    Some(DiscoveredSkill {
2093        name,
2094        description: if description.is_empty() {
2095            "No description".into()
2096        } else {
2097            description
2098        },
2099        source: path.to_string_lossy().to_string(),
2100        license: None,
2101        compatibility: None,
2102    })
2103}
2104
2105#[cfg(test)]
2106mod status_tests {
2107    use super::{parse_git_status_porcelain, FileChangeStatus};
2108
2109    #[test]
2110    fn parse_git_status_porcelain_maps_statuses() {
2111        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";
2112        let files = parse_git_status_porcelain(output);
2113
2114        assert_eq!(files.len(), 6);
2115        assert_eq!(files[0].status, FileChangeStatus::Modified);
2116        assert_eq!(files[1].status, FileChangeStatus::Added);
2117        assert_eq!(files[2].status, FileChangeStatus::Deleted);
2118        assert_eq!(files[3].status, FileChangeStatus::Renamed);
2119        assert_eq!(files[3].previous_path.as_deref(), Some("src/was.ts"));
2120        assert_eq!(files[3].path, "src/now.ts");
2121        assert_eq!(files[4].status, FileChangeStatus::Untracked);
2122        assert_eq!(files[5].status, FileChangeStatus::Conflicted);
2123    }
2124}
2125
2126/// Extract YAML frontmatter from between `---` delimiters.
2127fn extract_frontmatter_str(contents: &str) -> Option<(String, String)> {
2128    let mut lines = contents.lines();
2129    if !matches!(lines.next(), Some(line) if line.trim() == "---") {
2130        return None;
2131    }
2132
2133    let mut frontmatter_lines: Vec<&str> = Vec::new();
2134    let mut body_start = false;
2135    let mut body_lines: Vec<&str> = Vec::new();
2136
2137    for line in lines {
2138        if !body_start {
2139            if line.trim() == "---" {
2140                body_start = true;
2141            } else {
2142                frontmatter_lines.push(line);
2143            }
2144        } else {
2145            body_lines.push(line);
2146        }
2147    }
2148
2149    if frontmatter_lines.is_empty() || !body_start {
2150        return None;
2151    }
2152
2153    Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
2154}
2155
2156// ─── Git Worktree Operations ────────────────────────────────────────────
2157
2158/// Base directory for worktrees: ~/.routa/worktrees/
2159pub fn get_worktree_base_dir() -> PathBuf {
2160    dirs::home_dir()
2161        .unwrap_or_else(|| PathBuf::from("."))
2162        .join(".routa")
2163        .join("worktrees")
2164}
2165
2166/// Default worktree root for a workspace: ~/.routa/workspace/{workspaceId}
2167pub fn get_default_workspace_worktree_root(workspace_id: &str) -> PathBuf {
2168    dirs::home_dir()
2169        .unwrap_or_else(|| PathBuf::from("."))
2170        .join(".routa")
2171        .join("workspace")
2172        .join(workspace_id)
2173}
2174
2175/// Sanitize a branch name for use as a directory name.
2176pub fn branch_to_safe_dir_name(branch: &str) -> String {
2177    branch
2178        .chars()
2179        .map(|c| {
2180            if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' {
2181                c
2182            } else {
2183                '-'
2184            }
2185        })
2186        .collect()
2187}
2188
2189/// Prune stale worktree references.
2190pub fn worktree_prune(repo_path: &str) -> Result<(), String> {
2191    let output = Command::new("git")
2192        .args(["worktree", "prune"])
2193        .current_dir(repo_path)
2194        .output()
2195        .map_err(|e| e.to_string())?;
2196    if output.status.success() {
2197        Ok(())
2198    } else {
2199        Err(String::from_utf8_lossy(&output.stderr).to_string())
2200    }
2201}
2202
2203/// Add a new git worktree. If `create_branch` is true, creates a new branch.
2204pub fn worktree_add(
2205    repo_path: &str,
2206    worktree_path: &str,
2207    branch: &str,
2208    base_branch: &str,
2209    create_branch: bool,
2210) -> Result<(), String> {
2211    // Ensure parent directory exists
2212    if let Some(parent) = Path::new(worktree_path).parent() {
2213        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
2214    }
2215
2216    let args = if create_branch {
2217        vec![
2218            "worktree".to_string(),
2219            "add".to_string(),
2220            "-b".to_string(),
2221            branch.to_string(),
2222            worktree_path.to_string(),
2223            base_branch.to_string(),
2224        ]
2225    } else {
2226        vec![
2227            "worktree".to_string(),
2228            "add".to_string(),
2229            worktree_path.to_string(),
2230            branch.to_string(),
2231        ]
2232    };
2233
2234    let output = Command::new("git")
2235        .args(&args)
2236        .current_dir(repo_path)
2237        .output()
2238        .map_err(|e| e.to_string())?;
2239
2240    if output.status.success() {
2241        Ok(())
2242    } else {
2243        Err(String::from_utf8_lossy(&output.stderr).to_string())
2244    }
2245}
2246
2247/// Remove a git worktree.
2248pub fn worktree_remove(repo_path: &str, worktree_path: &str, force: bool) -> Result<(), String> {
2249    let mut args = vec!["worktree", "remove"];
2250    if force {
2251        args.push("--force");
2252    }
2253    args.push(worktree_path);
2254
2255    let output = Command::new("git")
2256        .args(&args)
2257        .current_dir(repo_path)
2258        .output()
2259        .map_err(|e| e.to_string())?;
2260
2261    if output.status.success() {
2262        Ok(())
2263    } else {
2264        Err(String::from_utf8_lossy(&output.stderr).to_string())
2265    }
2266}
2267
2268#[derive(Debug, Clone, Serialize, Deserialize)]
2269#[serde(rename_all = "camelCase")]
2270pub struct WorktreeListEntry {
2271    pub path: String,
2272    pub head: String,
2273    pub branch: String,
2274}
2275
2276/// List all worktrees for a repository.
2277pub fn worktree_list(repo_path: &str) -> Vec<WorktreeListEntry> {
2278    let output = match Command::new("git")
2279        .args(["worktree", "list", "--porcelain"])
2280        .current_dir(repo_path)
2281        .output()
2282    {
2283        Ok(o) if o.status.success() => o,
2284        _ => return vec![],
2285    };
2286
2287    let text = String::from_utf8_lossy(&output.stdout);
2288    let mut entries = Vec::new();
2289    let mut current_path = String::new();
2290    let mut current_head = String::new();
2291    let mut current_branch = String::new();
2292
2293    for line in text.lines() {
2294        if let Some(p) = line.strip_prefix("worktree ") {
2295            if !current_path.is_empty() {
2296                entries.push(WorktreeListEntry {
2297                    path: std::mem::take(&mut current_path),
2298                    head: std::mem::take(&mut current_head),
2299                    branch: std::mem::take(&mut current_branch),
2300                });
2301            }
2302            current_path = p.to_string();
2303        } else if let Some(h) = line.strip_prefix("HEAD ") {
2304            current_head = h.to_string();
2305        } else if let Some(b) = line.strip_prefix("branch ") {
2306            // "refs/heads/branch-name" -> "branch-name"
2307            current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
2308        }
2309    }
2310
2311    // Push last entry
2312    if !current_path.is_empty() {
2313        entries.push(WorktreeListEntry {
2314            path: current_path,
2315            head: current_head,
2316            branch: current_branch,
2317        });
2318    }
2319
2320    entries
2321}
2322
2323/// Check if a local branch exists.
2324pub fn branch_exists(repo_path: &str, branch: &str) -> bool {
2325    Command::new("git")
2326        .args(["branch", "--list", branch])
2327        .current_dir(repo_path)
2328        .output()
2329        .ok()
2330        .filter(|o| o.status.success())
2331        .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
2332        .unwrap_or(false)
2333}
2334
2335/// Recursively copy a directory, skipping .git and node_modules.
2336pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
2337    std::fs::create_dir_all(dest)?;
2338    // Internal helper for copying already-resolved local skill directories.
2339    // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
2340    for entry in std::fs::read_dir(src)? {
2341        let entry = entry?;
2342        let src_path = entry.path();
2343        let dest_path = dest.join(entry.file_name());
2344
2345        if src_path.is_dir() {
2346            let name = entry.file_name();
2347            let name_str = name.to_string_lossy();
2348            if name_str == ".git" || name_str == "node_modules" {
2349                continue;
2350            }
2351            copy_dir_recursive(&src_path, &dest_path)?;
2352        } else {
2353            std::fs::copy(&src_path, &dest_path)?;
2354        }
2355    }
2356    Ok(())
2357}
2358
2359#[cfg(test)]
2360mod tests {
2361    use super::*;
2362    use std::fs;
2363    use tempfile::tempdir;
2364
2365    #[test]
2366    fn parse_github_url_supports_multiple_formats() {
2367        let https = parse_github_url("https://github.com/phodal/routa-js.git").unwrap();
2368        assert_eq!(https.owner, "phodal");
2369        assert_eq!(https.repo, "routa-js");
2370
2371        let ssh = parse_github_url("git@github.com:owner/repo-name.git").unwrap();
2372        assert_eq!(ssh.owner, "owner");
2373        assert_eq!(ssh.repo, "repo-name");
2374
2375        let shorthand = parse_github_url("foo/bar.baz").unwrap();
2376        assert_eq!(shorthand.owner, "foo");
2377        assert_eq!(shorthand.repo, "bar.baz");
2378
2379        assert!(parse_github_url(r"C:\tmp\repo").is_none());
2380    }
2381
2382    #[test]
2383    fn repo_dir_name_conversions_are_stable() {
2384        let dir = repo_to_dir_name("org", "project");
2385        assert_eq!(dir, "org--project");
2386        assert_eq!(dir_name_to_repo(&dir), "org/project");
2387        assert_eq!(dir_name_to_repo("no-separator"), "no-separator");
2388    }
2389
2390    #[test]
2391    fn frontmatter_extraction_requires_both_delimiters() {
2392        let content = "---\nname: demo\ndescription: hello\n---\nbody";
2393        let (fm, body) = extract_frontmatter_str(content).unwrap();
2394        assert!(fm.contains("name: demo"));
2395        assert_eq!(body, "body");
2396
2397        assert!(extract_frontmatter_str("name: x\n---\nbody").is_none());
2398        assert!(extract_frontmatter_str("---\nname: x\nbody").is_none());
2399    }
2400
2401    #[test]
2402    fn parse_discovered_skill_supports_frontmatter_and_fallback() {
2403        let temp = tempdir().unwrap();
2404        let skill_dir = temp.path().join("skills").join("demo");
2405        fs::create_dir_all(&skill_dir).unwrap();
2406
2407        let fm_skill = skill_dir.join("SKILL.md");
2408        fs::write(
2409            &fm_skill,
2410            "---\nname: Demo Skill\ndescription: Does demo things\nlicense: MIT\ncompatibility: rust\n---\n# Body\n",
2411        )
2412        .unwrap();
2413
2414        let parsed = parse_discovered_skill(&fm_skill).unwrap();
2415        assert_eq!(parsed.name, "Demo Skill");
2416        assert_eq!(parsed.description, "Does demo things");
2417        assert_eq!(parsed.license.as_deref(), Some("MIT"));
2418        assert_eq!(parsed.compatibility.as_deref(), Some("rust"));
2419
2420        let fallback_dir = temp.path().join("skills").join("fallback-skill");
2421        fs::create_dir_all(&fallback_dir).unwrap();
2422        let fallback_file = fallback_dir.join("SKILL.md");
2423        fs::write(
2424            &fallback_file,
2425            "# Title\n\nFirst line of fallback description.\nSecond line.\n\n## Next section\n",
2426        )
2427        .unwrap();
2428
2429        let fallback = parse_discovered_skill(&fallback_file).unwrap();
2430        assert_eq!(fallback.name, "fallback-skill");
2431        assert_eq!(
2432            fallback.description,
2433            "First line of fallback description. Second line."
2434        );
2435        assert!(fallback.license.is_none());
2436        assert!(fallback.compatibility.is_none());
2437    }
2438
2439    #[test]
2440    fn discover_skills_from_path_scans_known_locations_and_root() {
2441        let temp = tempdir().unwrap();
2442
2443        let skill_paths = [
2444            temp.path().join("skills").join("a").join("SKILL.md"),
2445            temp.path()
2446                .join(".agents/skills")
2447                .join("b")
2448                .join("SKILL.md"),
2449            temp.path()
2450                .join(".opencode/skills")
2451                .join("c")
2452                .join("SKILL.md"),
2453            temp.path()
2454                .join(".claude/skills")
2455                .join("d")
2456                .join("SKILL.md"),
2457            temp.path().join("SKILL.md"),
2458        ];
2459
2460        for path in &skill_paths {
2461            fs::create_dir_all(path.parent().unwrap()).unwrap();
2462        }
2463
2464        fs::write(
2465            &skill_paths[0],
2466            "---\nname: skill-a\ndescription: from skills\n---\n",
2467        )
2468        .unwrap();
2469        fs::write(
2470            &skill_paths[1],
2471            "---\nname: skill-b\ndescription: from agents\n---\n",
2472        )
2473        .unwrap();
2474        fs::write(
2475            &skill_paths[2],
2476            "---\nname: skill-c\ndescription: from opencode\n---\n",
2477        )
2478        .unwrap();
2479        fs::write(
2480            &skill_paths[3],
2481            "---\nname: skill-d\ndescription: from claude\n---\n",
2482        )
2483        .unwrap();
2484        fs::write(
2485            &skill_paths[4],
2486            "---\nname: root-skill\ndescription: from root\n---\n",
2487        )
2488        .unwrap();
2489
2490        let discovered = discover_skills_from_path(temp.path());
2491        let mut names = discovered.into_iter().map(|s| s.name).collect::<Vec<_>>();
2492        names.sort();
2493        assert_eq!(
2494            names,
2495            vec![
2496                "root-skill".to_string(),
2497                "skill-a".to_string(),
2498                "skill-b".to_string(),
2499                "skill-c".to_string(),
2500                "skill-d".to_string()
2501            ]
2502        );
2503    }
2504
2505    #[test]
2506    fn branch_to_safe_dir_name_replaces_unsafe_chars() {
2507        assert_eq!(
2508            branch_to_safe_dir_name("feature/new ui@2026"),
2509            "feature-new-ui-2026"
2510        );
2511        assert_eq!(branch_to_safe_dir_name("release-1.2.3"), "release-1.2.3");
2512    }
2513
2514    #[test]
2515    fn copy_dir_recursive_skips_git_and_node_modules() {
2516        let temp = tempdir().unwrap();
2517        let src = temp.path().join("src");
2518        let dest = temp.path().join("dest");
2519
2520        fs::create_dir_all(src.join(".git")).unwrap();
2521        fs::create_dir_all(src.join("node_modules/pkg")).unwrap();
2522        fs::create_dir_all(src.join("nested")).unwrap();
2523
2524        fs::write(src.join(".git/config"), "ignored").unwrap();
2525        fs::write(src.join("node_modules/pkg/index.js"), "ignored").unwrap();
2526        fs::write(src.join("nested/kept.txt"), "hello").unwrap();
2527        fs::write(src.join("root.txt"), "root").unwrap();
2528
2529        copy_dir_recursive(&src, &dest).unwrap();
2530
2531        assert!(dest.join("root.txt").is_file());
2532        assert!(dest.join("nested/kept.txt").is_file());
2533        assert!(!dest.join(".git").exists());
2534        assert!(!dest.join("node_modules").exists());
2535    }
2536}