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