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