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