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