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