1use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::cmp::Ordering;
7use std::collections::{BTreeSet, HashMap};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ParsedGitHubUrl {
13 pub owner: String,
14 pub repo: String,
15}
16
17pub fn parse_github_url(url: &str) -> Option<ParsedGitHubUrl> {
19 let trimmed = url.trim();
20
21 let patterns = [
22 r"^https?://github\.com/([^/]+)/([^/\s#?.]+)",
23 r"^git@github\.com:([^/]+)/([^/\s#?.]+)",
24 r"^github\.com/([^/]+)/([^/\s#?.]+)",
25 ];
26
27 for pattern in &patterns {
28 if let Ok(re) = Regex::new(pattern) {
29 if let Some(caps) = re.captures(trimmed) {
30 let owner = caps.get(1)?.as_str().to_string();
31 let repo = caps.get(2)?.as_str().trim_end_matches(".git").to_string();
32 return Some(ParsedGitHubUrl { owner, repo });
33 }
34 }
35 }
36
37 if let Ok(re) = Regex::new(r"^([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)$") {
38 if let Some(caps) = re.captures(trimmed) {
39 if !trimmed.contains('\\') && !trimmed.contains(':') {
40 let owner = caps.get(1)?.as_str().to_string();
41 let repo = caps.get(2)?.as_str().to_string();
42 return Some(ParsedGitHubUrl { owner, repo });
43 }
44 }
45 }
46
47 None
48}
49
50pub fn get_clone_base_dir() -> PathBuf {
52 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
53 if cwd.parent().is_none() {
54 if let Some(home) = dirs::home_dir() {
55 return home.join(".routa").join("repos");
56 }
57 }
58 cwd.join(".routa").join("repos")
59}
60
61pub fn repo_to_dir_name(owner: &str, repo: &str) -> String {
62 format!("{}--{}", owner, repo)
63}
64
65pub fn dir_name_to_repo(dir_name: &str) -> String {
66 let parts: Vec<&str> = dir_name.splitn(2, "--").collect();
67 if parts.len() == 2 {
68 format!("{}/{}", parts[0], parts[1])
69 } else {
70 dir_name.to_string()
71 }
72}
73
74pub fn is_git_repository(repo_path: &str) -> bool {
75 Command::new("git")
76 .args(["rev-parse", "--git-dir"])
77 .current_dir(repo_path)
78 .output()
79 .map(|o| o.status.success())
80 .unwrap_or(false)
81}
82
83pub fn get_current_branch(repo_path: &str) -> Option<String> {
84 let output = Command::new("git")
85 .args(["rev-parse", "--abbrev-ref", "HEAD"])
86 .current_dir(repo_path)
87 .output()
88 .ok()?;
89 if output.status.success() {
90 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
91 if s.is_empty() {
92 None
93 } else {
94 Some(s)
95 }
96 } else {
97 None
98 }
99}
100
101pub fn list_local_branches(repo_path: &str) -> Vec<String> {
102 Command::new("git")
103 .args(["branch", "--format=%(refname:short)"])
104 .current_dir(repo_path)
105 .output()
106 .ok()
107 .filter(|o| o.status.success())
108 .map(|o| {
109 String::from_utf8_lossy(&o.stdout)
110 .lines()
111 .map(|l| l.trim().to_string())
112 .filter(|l| !l.is_empty())
113 .collect()
114 })
115 .unwrap_or_default()
116}
117
118pub fn list_remote_branches(repo_path: &str) -> Vec<String> {
119 Command::new("git")
120 .args(["branch", "-r", "--format=%(refname:short)"])
121 .current_dir(repo_path)
122 .output()
123 .ok()
124 .filter(|o| o.status.success())
125 .map(|o| {
126 String::from_utf8_lossy(&o.stdout)
127 .lines()
128 .map(|l| l.trim().to_string())
129 .filter(|l| !l.is_empty() && !l.contains("HEAD"))
130 .map(|l| l.trim_start_matches("origin/").to_string())
131 .collect()
132 })
133 .unwrap_or_default()
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct RepoBranchInfo {
138 pub current: String,
139 pub branches: Vec<String>,
140}
141
142pub fn get_branch_info(repo_path: &str) -> RepoBranchInfo {
143 RepoBranchInfo {
144 current: get_current_branch(repo_path).unwrap_or_else(|| "unknown".into()),
145 branches: list_local_branches(repo_path),
146 }
147}
148
149pub fn checkout_branch(repo_path: &str, branch: &str) -> bool {
150 let ok = Command::new("git")
151 .args(["checkout", branch])
152 .current_dir(repo_path)
153 .output()
154 .map(|o| o.status.success())
155 .unwrap_or(false);
156 if ok {
157 return true;
158 }
159 Command::new("git")
160 .args(["checkout", "-b", branch])
161 .current_dir(repo_path)
162 .output()
163 .map(|o| o.status.success())
164 .unwrap_or(false)
165}
166
167pub fn delete_branch(repo_path: &str, branch: &str) -> Result<(), String> {
168 let current_branch = get_current_branch(repo_path).unwrap_or_default();
169 if current_branch == branch {
170 return Err(format!("Cannot delete the current branch '{}'", branch));
171 }
172
173 if !list_local_branches(repo_path)
174 .iter()
175 .any(|candidate| candidate == branch)
176 {
177 return Err(format!("Branch '{}' not found", branch));
178 }
179
180 let output = Command::new("git")
181 .args(["branch", "-D", branch])
182 .current_dir(repo_path)
183 .output()
184 .map_err(|e| e.to_string())?;
185
186 if output.status.success() {
187 Ok(())
188 } else {
189 Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
190 }
191}
192
193pub fn fetch_remote(repo_path: &str) -> bool {
194 Command::new("git")
195 .args(["fetch", "--all", "--prune"])
196 .current_dir(repo_path)
197 .output()
198 .map(|o| o.status.success())
199 .unwrap_or(false)
200}
201
202pub fn pull_branch(repo_path: &str) -> Result<(), String> {
203 let output = Command::new("git")
204 .args(["pull", "--ff-only"])
205 .current_dir(repo_path)
206 .output()
207 .map_err(|e| e.to_string())?;
208 if output.status.success() {
209 Ok(())
210 } else {
211 Err(String::from_utf8_lossy(&output.stderr).to_string())
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct BranchStatus {
218 pub ahead: i32,
219 pub behind: i32,
220 pub has_uncommitted_changes: bool,
221}
222
223pub fn get_branch_status(repo_path: &str, branch: &str) -> BranchStatus {
224 let mut result = BranchStatus {
225 ahead: 0,
226 behind: 0,
227 has_uncommitted_changes: false,
228 };
229
230 if let Ok(o) = Command::new("git")
231 .args([
232 "rev-list",
233 "--left-right",
234 "--count",
235 &format!("{}...origin/{}", branch, branch),
236 ])
237 .current_dir(repo_path)
238 .output()
239 {
240 if o.status.success() {
241 let text = String::from_utf8_lossy(&o.stdout);
242 let parts: Vec<&str> = text.split_whitespace().collect();
243 if parts.len() == 2 {
244 result.ahead = parts[0].parse().unwrap_or(0);
245 result.behind = parts[1].parse().unwrap_or(0);
246 }
247 }
248 }
249
250 if let Ok(o) = Command::new("git")
251 .args(["status", "--porcelain", "-uall"])
252 .current_dir(repo_path)
253 .output()
254 {
255 if o.status.success() {
256 result.has_uncommitted_changes = !String::from_utf8_lossy(&o.stdout).trim().is_empty();
257 }
258 }
259
260 result
261}
262
263pub fn reset_local_changes(repo_path: &str) -> Result<(), String> {
264 let reset_output = Command::new("git")
265 .args(["reset", "--hard", "HEAD"])
266 .current_dir(repo_path)
267 .output()
268 .map_err(|e| e.to_string())?;
269 if !reset_output.status.success() {
270 return Err(String::from_utf8_lossy(&reset_output.stderr)
271 .trim()
272 .to_string());
273 }
274
275 let clean_output = Command::new("git")
276 .args(["clean", "-fd"])
277 .current_dir(repo_path)
278 .output()
279 .map_err(|e| e.to_string())?;
280 if !clean_output.status.success() {
281 return Err(String::from_utf8_lossy(&clean_output.stderr)
282 .trim()
283 .to_string());
284 }
285
286 Ok(())
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct RepoStatus {
292 pub clean: bool,
293 pub ahead: i32,
294 pub behind: i32,
295 pub modified: i32,
296 pub untracked: i32,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300#[serde(rename_all = "camelCase")]
301pub enum FileChangeStatus {
302 Modified,
303 Added,
304 Deleted,
305 Renamed,
306 Copied,
307 Untracked,
308 Typechange,
309 Conflicted,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "camelCase")]
314pub struct GitFileChange {
315 pub path: String,
316 pub status: FileChangeStatus,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub previous_path: Option<String>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct RepoChanges {
324 pub branch: String,
325 pub status: RepoStatus,
326 pub files: Vec<GitFileChange>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct HistoricalRelatedFile {
332 pub path: String,
333 pub score: f64,
334 pub source_files: Vec<String>,
335 pub related_commits: Vec<String>,
336}
337
338#[derive(Default)]
339struct HistoricalCandidateAggregate {
340 hits: u32,
341 source_files: BTreeSet<String>,
342 related_commits: BTreeSet<String>,
343}
344
345#[derive(Debug, Clone)]
346struct BlameChunk {
347 commit: String,
348 start: u32,
349 end: u32,
350}
351
352pub fn get_repo_status(repo_path: &str) -> RepoStatus {
353 let mut status = RepoStatus {
354 clean: true,
355 ahead: 0,
356 behind: 0,
357 modified: 0,
358 untracked: 0,
359 };
360
361 if let Ok(o) = Command::new("git")
362 .args(["status", "--porcelain", "-uall"])
363 .current_dir(repo_path)
364 .output()
365 {
366 if o.status.success() {
367 let text = String::from_utf8_lossy(&o.stdout);
368 let lines: Vec<&str> = text.lines().filter(|l| !l.is_empty()).collect();
369 status.modified = lines.iter().filter(|l| !l.starts_with("??")).count() as i32;
370 status.untracked = lines.iter().filter(|l| l.starts_with("??")).count() as i32;
371 status.clean = lines.is_empty();
372 }
373 }
374
375 if let Ok(o) = Command::new("git")
376 .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
377 .current_dir(repo_path)
378 .output()
379 {
380 if o.status.success() {
381 let text = String::from_utf8_lossy(&o.stdout);
382 let parts: Vec<&str> = text.split_whitespace().collect();
383 if parts.len() == 2 {
384 status.ahead = parts[0].parse().unwrap_or(0);
385 status.behind = parts[1].parse().unwrap_or(0);
386 }
387 }
388 }
389
390 status
391}
392
393fn map_porcelain_status(code: &str) -> FileChangeStatus {
394 if code == "??" {
395 return FileChangeStatus::Untracked;
396 }
397
398 let mut chars = code.chars();
399 let index_status = chars.next().unwrap_or(' ');
400 let worktree_status = chars.next().unwrap_or(' ');
401
402 if index_status == 'U' || worktree_status == 'U' || code == "AA" || code == "DD" {
403 return FileChangeStatus::Conflicted;
404 }
405 if index_status == 'R' || worktree_status == 'R' {
406 return FileChangeStatus::Renamed;
407 }
408 if index_status == 'C' || worktree_status == 'C' {
409 return FileChangeStatus::Copied;
410 }
411 if index_status == 'A' || worktree_status == 'A' {
412 return FileChangeStatus::Added;
413 }
414 if index_status == 'D' || worktree_status == 'D' {
415 return FileChangeStatus::Deleted;
416 }
417 if index_status == 'T' || worktree_status == 'T' {
418 return FileChangeStatus::Typechange;
419 }
420 FileChangeStatus::Modified
421}
422
423pub fn parse_git_status_porcelain(output: &str) -> Vec<GitFileChange> {
424 output
425 .lines()
426 .filter(|line| !line.trim().is_empty())
427 .filter_map(|line| {
428 if line.len() < 3 {
429 return None;
430 }
431
432 let code = &line[0..2];
433 if code == "!!" {
434 return None;
435 }
436
437 let raw_path = line[3..].trim().to_string();
438 let status = map_porcelain_status(code);
439
440 if matches!(status, FileChangeStatus::Renamed | FileChangeStatus::Copied)
441 && raw_path.contains(" -> ")
442 {
443 let parts: Vec<&str> = raw_path.splitn(2, " -> ").collect();
444 if parts.len() == 2 {
445 return Some(GitFileChange {
446 path: parts[1].to_string(),
447 previous_path: Some(parts[0].to_string()),
448 status,
449 });
450 }
451 }
452
453 Some(GitFileChange {
454 path: raw_path,
455 previous_path: None,
456 status,
457 })
458 })
459 .collect()
460}
461
462pub fn get_repo_changes(repo_path: &str) -> RepoChanges {
463 let branch = get_current_branch(repo_path).unwrap_or_else(|| "unknown".into());
464 let status = get_repo_status(repo_path);
465 let files = Command::new("git")
466 .args(["status", "--porcelain", "-uall"])
467 .current_dir(repo_path)
468 .output()
469 .ok()
470 .filter(|o| o.status.success())
471 .map(|o| parse_git_status_porcelain(&String::from_utf8_lossy(&o.stdout)))
472 .unwrap_or_default();
473
474 RepoChanges {
475 branch,
476 status,
477 files,
478 }
479}
480
481fn git_output_at_path(repo_root: &Path, args: &[&str]) -> Result<String, String> {
482 let output = Command::new("git")
483 .args(args)
484 .current_dir(repo_root)
485 .output()
486 .map_err(|err| format!("Failed to run git {}: {}", args.join(" "), err))?;
487
488 if output.status.success() {
489 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
490 } else {
491 Err(format!(
492 "git {} failed: {}",
493 args.join(" "),
494 String::from_utf8_lossy(&output.stderr).trim()
495 ))
496 }
497}
498
499pub fn compute_historical_related_files(
503 repo_root: &Path,
504 diff_range: &str,
505 head: &str,
506 max_results: usize,
507) -> Result<Vec<HistoricalRelatedFile>, String> {
508 let changed_files: Vec<String> =
509 git_output_at_path(repo_root, &["diff", "--name-only", diff_range])?
510 .lines()
511 .map(str::trim)
512 .filter(|line| !line.is_empty())
513 .map(str::to_string)
514 .collect();
515
516 if changed_files.is_empty() {
517 return Ok(Vec::new());
518 }
519
520 let source_files: Vec<String> = changed_files.into_iter().take(8).collect();
521 let changed_file_set: BTreeSet<String> = source_files.iter().cloned().collect();
522 let mut candidate_map: HashMap<String, HistoricalCandidateAggregate> = HashMap::new();
523 let mut blame_cache: HashMap<String, Vec<BlameChunk>> = HashMap::new();
524 let mut commit_paths_cache: HashMap<String, Vec<String>> = HashMap::new();
525
526 for source_file in &source_files {
527 if !file_exists_at_revision(repo_root, head, source_file) {
528 continue;
529 }
530
531 let line_samples = collect_interesting_lines(repo_root, diff_range, source_file)?;
532 if line_samples.is_empty() {
533 continue;
534 }
535
536 let blame_chunks = load_blame_chunks(repo_root, head, source_file, &mut blame_cache)?;
537 if blame_chunks.is_empty() {
538 continue;
539 }
540
541 let mut interesting_commits: Vec<(String, u32)> =
542 collect_interesting_commits(&blame_chunks, &line_samples)
543 .into_iter()
544 .collect();
545 interesting_commits
546 .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
547 interesting_commits.truncate(8);
548
549 for (commit_sha, hits) in interesting_commits {
550 let changed_in_commit =
551 load_changed_files_for_commit(repo_root, &commit_sha, &mut commit_paths_cache)?;
552
553 for candidate_path in changed_in_commit {
554 if candidate_path.is_empty()
555 || candidate_path == *source_file
556 || changed_file_set.contains(&candidate_path)
557 {
558 continue;
559 }
560
561 let entry = candidate_map.entry(candidate_path).or_default();
562 entry.hits = entry.hits.saturating_add(hits);
563 entry.source_files.insert(source_file.clone());
564 entry.related_commits.insert(commit_sha.clone());
565 }
566 }
567 }
568
569 if candidate_map.is_empty() {
570 return Ok(Vec::new());
571 }
572
573 let mut related_files: Vec<HistoricalRelatedFile> = candidate_map
574 .into_iter()
575 .map(|(path, aggregate)| HistoricalRelatedFile {
576 path,
577 score: aggregate.hits as f64,
578 source_files: aggregate.source_files.into_iter().collect(),
579 related_commits: aggregate.related_commits.into_iter().collect(),
580 })
581 .collect();
582
583 related_files.sort_by(|left, right| {
584 right
585 .score
586 .partial_cmp(&left.score)
587 .unwrap_or(Ordering::Equal)
588 .then_with(|| right.source_files.len().cmp(&left.source_files.len()))
589 .then_with(|| left.path.cmp(&right.path))
590 });
591
592 if max_results > 0 && related_files.len() > max_results {
593 related_files.truncate(max_results);
594 }
595
596 Ok(related_files)
597}
598
599fn file_exists_at_revision(repo_root: &Path, revision: &str, file_path: &str) -> bool {
600 Command::new("git")
601 .args(["cat-file", "-e", &format!("{}:{}", revision, file_path)])
602 .current_dir(repo_root)
603 .output()
604 .map(|output| output.status.success())
605 .unwrap_or(false)
606}
607
608fn collect_interesting_lines(
609 repo_root: &Path,
610 diff_range: &str,
611 file_path: &str,
612) -> Result<Vec<u32>, String> {
613 let raw_diff = git_output_at_path(
614 repo_root,
615 &["diff", "--unified=0", diff_range, "--", file_path],
616 )?;
617 if raw_diff.is_empty() {
618 return Ok(Vec::new());
619 }
620
621 let hunk_pattern = Regex::new(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
622 .map_err(|err| format!("Failed to compile diff hunk regex: {}", err))?;
623 let mut interesting_lines = BTreeSet::new();
624
625 for line in raw_diff.lines() {
626 let Some(captures) = hunk_pattern.captures(line) else {
627 continue;
628 };
629
630 let start = captures
631 .get(1)
632 .and_then(|value| value.as_str().parse::<u32>().ok())
633 .unwrap_or(0);
634 let count = captures
635 .get(2)
636 .and_then(|value| value.as_str().parse::<u32>().ok())
637 .unwrap_or(1);
638 let span = if count == 0 { 1 } else { count };
639 let end = start.saturating_add(span.saturating_sub(1));
640
641 for line_number in [start.saturating_sub(1), start, end, end.saturating_add(1)] {
642 if line_number > 0 {
643 interesting_lines.insert(line_number);
644 }
645 }
646 }
647
648 Ok(interesting_lines.into_iter().collect())
649}
650
651fn load_blame_chunks(
652 repo_root: &Path,
653 revision: &str,
654 file_path: &str,
655 cache: &mut HashMap<String, Vec<BlameChunk>>,
656) -> Result<Vec<BlameChunk>, String> {
657 let cache_key = format!("{}:{}", revision, file_path);
658 if let Some(chunks) = cache.get(&cache_key) {
659 return Ok(chunks.clone());
660 }
661
662 let raw_blame = match git_output_at_path(
663 repo_root,
664 &["blame", "--incremental", revision, "--", file_path],
665 ) {
666 Ok(output) => output,
667 Err(_) => {
668 cache.insert(cache_key, Vec::new());
669 return Ok(Vec::new());
670 }
671 };
672
673 let header_pattern = Regex::new(r"^([0-9a-f]{40}) \d+ (\d+) (\d+)$")
674 .map_err(|err| format!("Failed to compile blame regex: {}", err))?;
675 let mut chunks = Vec::new();
676 let mut current_chunk: Option<BlameChunk> = None;
677
678 for line in raw_blame.lines() {
679 if let Some(captures) = header_pattern.captures(line) {
680 let commit = captures
681 .get(1)
682 .map(|value| value.as_str().to_string())
683 .unwrap_or_default();
684 let start = captures
685 .get(2)
686 .and_then(|value| value.as_str().parse::<u32>().ok())
687 .unwrap_or(0);
688 let num_lines = captures
689 .get(3)
690 .and_then(|value| value.as_str().parse::<u32>().ok())
691 .unwrap_or(0);
692 current_chunk = Some(BlameChunk {
693 commit,
694 start,
695 end: start.saturating_add(num_lines),
696 });
697 continue;
698 }
699
700 if line.starts_with("filename ") {
701 if let Some(chunk) = current_chunk.take() {
702 chunks.push(chunk);
703 }
704 }
705 }
706
707 chunks.sort_by(|left, right| left.start.cmp(&right.start));
708 cache.insert(cache_key, chunks.clone());
709 Ok(chunks)
710}
711
712fn collect_interesting_commits(
713 blame_chunks: &[BlameChunk],
714 line_numbers: &[u32],
715) -> HashMap<String, u32> {
716 let mut commit_hits = HashMap::new();
717
718 for line_number in line_numbers {
719 if let Some(chunk) = blame_chunks
720 .iter()
721 .find(|candidate| *line_number >= candidate.start && *line_number < candidate.end)
722 {
723 *commit_hits.entry(chunk.commit.clone()).or_insert(0) += 1;
724 }
725 }
726
727 commit_hits
728}
729
730fn load_changed_files_for_commit(
731 repo_root: &Path,
732 commit: &str,
733 cache: &mut HashMap<String, Vec<String>>,
734) -> Result<Vec<String>, String> {
735 if let Some(files) = cache.get(commit) {
736 return Ok(files.clone());
737 }
738
739 let raw_files = match git_output_at_path(
740 repo_root,
741 &[
742 "diff-tree",
743 "--root",
744 "--no-commit-id",
745 "--name-only",
746 "-r",
747 "-m",
748 commit,
749 ],
750 ) {
751 Ok(output) => output,
752 Err(_) => {
753 cache.insert(commit.to_string(), Vec::new());
754 return Ok(Vec::new());
755 }
756 };
757
758 let files: Vec<String> = raw_files
759 .lines()
760 .map(str::trim)
761 .filter(|line| !line.is_empty())
762 .map(str::to_string)
763 .collect::<BTreeSet<_>>()
764 .into_iter()
765 .collect();
766 cache.insert(commit.to_string(), files.clone());
767 Ok(files)
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize)]
771#[serde(rename_all = "camelCase")]
772pub struct ClonedRepoInfo {
773 pub name: String,
774 pub path: String,
775 pub dir_name: String,
776 pub branch: String,
777 pub branches: Vec<String>,
778 pub status: RepoStatus,
779}
780
781pub fn list_cloned_repos() -> Vec<ClonedRepoInfo> {
783 let base_dir = get_clone_base_dir();
784 if !base_dir.exists() {
785 return vec![];
786 }
787
788 let entries = match std::fs::read_dir(&base_dir) {
789 Ok(e) => e,
790 Err(_) => return vec![],
791 };
792
793 entries
794 .flatten()
795 .filter(|e| e.path().is_dir())
796 .map(|e| {
797 let full_path = e.path();
798 let dir_name = e.file_name().to_string_lossy().to_string();
799 let path_str = full_path.to_string_lossy().to_string();
800 let branch_info = get_branch_info(&path_str);
801 let repo_status = get_repo_status(&path_str);
802 ClonedRepoInfo {
803 name: dir_name_to_repo(&dir_name),
804 path: path_str,
805 dir_name,
806 branch: branch_info.current,
807 branches: branch_info.branches,
808 status: repo_status,
809 }
810 })
811 .collect()
812}
813
814pub fn discover_skills_from_path(repo_path: &Path) -> Vec<DiscoveredSkill> {
816 let dirs_to_check = [
817 "skills",
818 ".agents/skills",
819 ".opencode/skills",
820 ".claude/skills",
821 ];
822
823 let mut result = Vec::new();
824
825 for dir in &dirs_to_check {
826 let skill_dir = repo_path.join(dir);
827 if skill_dir.is_dir() {
828 scan_skill_dir(&skill_dir, &mut result);
829 }
830 }
831
832 let root_skill = repo_path.join("SKILL.md");
834 if root_skill.is_file() {
835 if let Some(skill) = parse_discovered_skill(&root_skill) {
836 result.push(skill);
837 }
838 }
839
840 result
841}
842
843#[derive(Debug, Clone, Serialize, Deserialize)]
844#[serde(rename_all = "camelCase")]
845pub struct DiscoveredSkill {
846 pub name: String,
847 pub description: String,
848 pub source: String,
849 #[serde(skip_serializing_if = "Option::is_none")]
850 pub license: Option<String>,
851 #[serde(skip_serializing_if = "Option::is_none")]
852 pub compatibility: Option<String>,
853}
854
855fn scan_skill_dir(dir: &Path, out: &mut Vec<DiscoveredSkill>) {
856 let entries = match std::fs::read_dir(dir) {
857 Ok(e) => e,
858 Err(_) => return,
859 };
860
861 for entry in entries.flatten() {
862 let path = entry.path();
863 if path.is_dir() {
864 let skill_file = path.join("SKILL.md");
865 if skill_file.is_file() {
866 if let Some(skill) = parse_discovered_skill(&skill_file) {
867 out.push(skill);
868 }
869 }
870 }
871 }
872}
873
874#[derive(Debug, serde::Deserialize)]
876struct SkillFrontmatter {
877 name: String,
878 description: String,
879 #[serde(default)]
880 license: Option<String>,
881 #[serde(default)]
882 compatibility: Option<String>,
883}
884
885fn parse_discovered_skill(path: &Path) -> Option<DiscoveredSkill> {
886 let content = std::fs::read_to_string(path).ok()?;
887
888 if let Some((fm_str, _body)) = extract_frontmatter_str(&content) {
890 if let Ok(fm) = serde_yaml::from_str::<SkillFrontmatter>(&fm_str) {
891 return Some(DiscoveredSkill {
892 name: fm.name,
893 description: fm.description,
894 source: path.to_string_lossy().to_string(),
895 license: fm.license,
896 compatibility: fm.compatibility,
897 });
898 }
899 }
900
901 let name = path
903 .parent()
904 .and_then(|p| p.file_name())
905 .map(|n| n.to_string_lossy().to_string())
906 .unwrap_or_else(|| "unknown".into());
907
908 let description = content
909 .lines()
910 .skip_while(|l| l.starts_with('#') || l.starts_with("---") || l.trim().is_empty())
911 .take_while(|l| !l.trim().is_empty())
912 .collect::<Vec<_>>()
913 .join(" ");
914
915 Some(DiscoveredSkill {
916 name,
917 description: if description.is_empty() {
918 "No description".into()
919 } else {
920 description
921 },
922 source: path.to_string_lossy().to_string(),
923 license: None,
924 compatibility: None,
925 })
926}
927
928#[cfg(test)]
929mod status_tests {
930 use super::{parse_git_status_porcelain, FileChangeStatus};
931
932 #[test]
933 fn parse_git_status_porcelain_maps_statuses() {
934 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";
935 let files = parse_git_status_porcelain(output);
936
937 assert_eq!(files.len(), 6);
938 assert_eq!(files[0].status, FileChangeStatus::Modified);
939 assert_eq!(files[1].status, FileChangeStatus::Added);
940 assert_eq!(files[2].status, FileChangeStatus::Deleted);
941 assert_eq!(files[3].status, FileChangeStatus::Renamed);
942 assert_eq!(files[3].previous_path.as_deref(), Some("src/was.ts"));
943 assert_eq!(files[3].path, "src/now.ts");
944 assert_eq!(files[4].status, FileChangeStatus::Untracked);
945 assert_eq!(files[5].status, FileChangeStatus::Conflicted);
946 }
947}
948
949fn extract_frontmatter_str(contents: &str) -> Option<(String, String)> {
951 let mut lines = contents.lines();
952 if !matches!(lines.next(), Some(line) if line.trim() == "---") {
953 return None;
954 }
955
956 let mut frontmatter_lines: Vec<&str> = Vec::new();
957 let mut body_start = false;
958 let mut body_lines: Vec<&str> = Vec::new();
959
960 for line in lines {
961 if !body_start {
962 if line.trim() == "---" {
963 body_start = true;
964 } else {
965 frontmatter_lines.push(line);
966 }
967 } else {
968 body_lines.push(line);
969 }
970 }
971
972 if frontmatter_lines.is_empty() || !body_start {
973 return None;
974 }
975
976 Some((frontmatter_lines.join("\n"), body_lines.join("\n")))
977}
978
979pub fn get_worktree_base_dir() -> PathBuf {
983 dirs::home_dir()
984 .unwrap_or_else(|| PathBuf::from("."))
985 .join(".routa")
986 .join("worktrees")
987}
988
989pub fn get_default_workspace_worktree_root(workspace_id: &str) -> PathBuf {
991 dirs::home_dir()
992 .unwrap_or_else(|| PathBuf::from("."))
993 .join(".routa")
994 .join("workspace")
995 .join(workspace_id)
996}
997
998pub fn branch_to_safe_dir_name(branch: &str) -> String {
1000 branch
1001 .chars()
1002 .map(|c| {
1003 if c.is_alphanumeric() || c == '.' || c == '_' || c == '-' {
1004 c
1005 } else {
1006 '-'
1007 }
1008 })
1009 .collect()
1010}
1011
1012pub fn worktree_prune(repo_path: &str) -> Result<(), String> {
1014 let output = Command::new("git")
1015 .args(["worktree", "prune"])
1016 .current_dir(repo_path)
1017 .output()
1018 .map_err(|e| e.to_string())?;
1019 if output.status.success() {
1020 Ok(())
1021 } else {
1022 Err(String::from_utf8_lossy(&output.stderr).to_string())
1023 }
1024}
1025
1026pub fn worktree_add(
1028 repo_path: &str,
1029 worktree_path: &str,
1030 branch: &str,
1031 base_branch: &str,
1032 create_branch: bool,
1033) -> Result<(), String> {
1034 if let Some(parent) = Path::new(worktree_path).parent() {
1036 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1037 }
1038
1039 let args = if create_branch {
1040 vec![
1041 "worktree".to_string(),
1042 "add".to_string(),
1043 "-b".to_string(),
1044 branch.to_string(),
1045 worktree_path.to_string(),
1046 base_branch.to_string(),
1047 ]
1048 } else {
1049 vec![
1050 "worktree".to_string(),
1051 "add".to_string(),
1052 worktree_path.to_string(),
1053 branch.to_string(),
1054 ]
1055 };
1056
1057 let output = Command::new("git")
1058 .args(&args)
1059 .current_dir(repo_path)
1060 .output()
1061 .map_err(|e| e.to_string())?;
1062
1063 if output.status.success() {
1064 Ok(())
1065 } else {
1066 Err(String::from_utf8_lossy(&output.stderr).to_string())
1067 }
1068}
1069
1070pub fn worktree_remove(repo_path: &str, worktree_path: &str, force: bool) -> Result<(), String> {
1072 let mut args = vec!["worktree", "remove"];
1073 if force {
1074 args.push("--force");
1075 }
1076 args.push(worktree_path);
1077
1078 let output = Command::new("git")
1079 .args(&args)
1080 .current_dir(repo_path)
1081 .output()
1082 .map_err(|e| e.to_string())?;
1083
1084 if output.status.success() {
1085 Ok(())
1086 } else {
1087 Err(String::from_utf8_lossy(&output.stderr).to_string())
1088 }
1089}
1090
1091#[derive(Debug, Clone, Serialize, Deserialize)]
1092#[serde(rename_all = "camelCase")]
1093pub struct WorktreeListEntry {
1094 pub path: String,
1095 pub head: String,
1096 pub branch: String,
1097}
1098
1099pub fn worktree_list(repo_path: &str) -> Vec<WorktreeListEntry> {
1101 let output = match Command::new("git")
1102 .args(["worktree", "list", "--porcelain"])
1103 .current_dir(repo_path)
1104 .output()
1105 {
1106 Ok(o) if o.status.success() => o,
1107 _ => return vec![],
1108 };
1109
1110 let text = String::from_utf8_lossy(&output.stdout);
1111 let mut entries = Vec::new();
1112 let mut current_path = String::new();
1113 let mut current_head = String::new();
1114 let mut current_branch = String::new();
1115
1116 for line in text.lines() {
1117 if let Some(p) = line.strip_prefix("worktree ") {
1118 if !current_path.is_empty() {
1119 entries.push(WorktreeListEntry {
1120 path: std::mem::take(&mut current_path),
1121 head: std::mem::take(&mut current_head),
1122 branch: std::mem::take(&mut current_branch),
1123 });
1124 }
1125 current_path = p.to_string();
1126 } else if let Some(h) = line.strip_prefix("HEAD ") {
1127 current_head = h.to_string();
1128 } else if let Some(b) = line.strip_prefix("branch ") {
1129 current_branch = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
1131 }
1132 }
1133
1134 if !current_path.is_empty() {
1136 entries.push(WorktreeListEntry {
1137 path: current_path,
1138 head: current_head,
1139 branch: current_branch,
1140 });
1141 }
1142
1143 entries
1144}
1145
1146pub fn branch_exists(repo_path: &str, branch: &str) -> bool {
1148 Command::new("git")
1149 .args(["branch", "--list", branch])
1150 .current_dir(repo_path)
1151 .output()
1152 .ok()
1153 .filter(|o| o.status.success())
1154 .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
1155 .unwrap_or(false)
1156}
1157
1158pub fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
1160 std::fs::create_dir_all(dest)?;
1161 for entry in std::fs::read_dir(src)? {
1164 let entry = entry?;
1165 let src_path = entry.path();
1166 let dest_path = dest.join(entry.file_name());
1167
1168 if src_path.is_dir() {
1169 let name = entry.file_name();
1170 let name_str = name.to_string_lossy();
1171 if name_str == ".git" || name_str == "node_modules" {
1172 continue;
1173 }
1174 copy_dir_recursive(&src_path, &dest_path)?;
1175 } else {
1176 std::fs::copy(&src_path, &dest_path)?;
1177 }
1178 }
1179 Ok(())
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185 use std::collections::HashSet;
1186 use std::fs;
1187 use std::path::Path;
1188 use std::process::Command;
1189 use tempfile::tempdir;
1190
1191 fn git(cwd: &Path, args: &[&str]) -> String {
1192 let output = Command::new("git")
1193 .args(args)
1194 .current_dir(cwd)
1195 .output()
1196 .expect("git command should run");
1197 assert!(
1198 output.status.success(),
1199 "git {:?} failed: {}",
1200 args,
1201 String::from_utf8_lossy(&output.stderr)
1202 );
1203 String::from_utf8_lossy(&output.stdout).trim().to_string()
1204 }
1205
1206 #[test]
1207 fn parse_github_url_supports_multiple_formats() {
1208 let https = parse_github_url("https://github.com/phodal/routa-js.git").unwrap();
1209 assert_eq!(https.owner, "phodal");
1210 assert_eq!(https.repo, "routa-js");
1211
1212 let ssh = parse_github_url("git@github.com:owner/repo-name.git").unwrap();
1213 assert_eq!(ssh.owner, "owner");
1214 assert_eq!(ssh.repo, "repo-name");
1215
1216 let shorthand = parse_github_url("foo/bar.baz").unwrap();
1217 assert_eq!(shorthand.owner, "foo");
1218 assert_eq!(shorthand.repo, "bar.baz");
1219
1220 assert!(parse_github_url(r"C:\tmp\repo").is_none());
1221 }
1222
1223 #[test]
1224 fn repo_dir_name_conversions_are_stable() {
1225 let dir = repo_to_dir_name("org", "project");
1226 assert_eq!(dir, "org--project");
1227 assert_eq!(dir_name_to_repo(&dir), "org/project");
1228 assert_eq!(dir_name_to_repo("no-separator"), "no-separator");
1229 }
1230
1231 #[test]
1232 fn frontmatter_extraction_requires_both_delimiters() {
1233 let content = "---\nname: demo\ndescription: hello\n---\nbody";
1234 let (fm, body) = extract_frontmatter_str(content).unwrap();
1235 assert!(fm.contains("name: demo"));
1236 assert_eq!(body, "body");
1237
1238 assert!(extract_frontmatter_str("name: x\n---\nbody").is_none());
1239 assert!(extract_frontmatter_str("---\nname: x\nbody").is_none());
1240 }
1241
1242 #[test]
1243 fn parse_discovered_skill_supports_frontmatter_and_fallback() {
1244 let temp = tempdir().unwrap();
1245 let skill_dir = temp.path().join("skills").join("demo");
1246 fs::create_dir_all(&skill_dir).unwrap();
1247
1248 let fm_skill = skill_dir.join("SKILL.md");
1249 fs::write(
1250 &fm_skill,
1251 "---\nname: Demo Skill\ndescription: Does demo things\nlicense: MIT\ncompatibility: rust\n---\n# Body\n",
1252 )
1253 .unwrap();
1254
1255 let parsed = parse_discovered_skill(&fm_skill).unwrap();
1256 assert_eq!(parsed.name, "Demo Skill");
1257 assert_eq!(parsed.description, "Does demo things");
1258 assert_eq!(parsed.license.as_deref(), Some("MIT"));
1259 assert_eq!(parsed.compatibility.as_deref(), Some("rust"));
1260
1261 let fallback_dir = temp.path().join("skills").join("fallback-skill");
1262 fs::create_dir_all(&fallback_dir).unwrap();
1263 let fallback_file = fallback_dir.join("SKILL.md");
1264 fs::write(
1265 &fallback_file,
1266 "# Title\n\nFirst line of fallback description.\nSecond line.\n\n## Next section\n",
1267 )
1268 .unwrap();
1269
1270 let fallback = parse_discovered_skill(&fallback_file).unwrap();
1271 assert_eq!(fallback.name, "fallback-skill");
1272 assert_eq!(
1273 fallback.description,
1274 "First line of fallback description. Second line."
1275 );
1276 assert!(fallback.license.is_none());
1277 assert!(fallback.compatibility.is_none());
1278 }
1279
1280 #[test]
1281 fn discover_skills_from_path_scans_known_locations_and_root() {
1282 let temp = tempdir().unwrap();
1283
1284 let skill_paths = [
1285 temp.path().join("skills").join("a").join("SKILL.md"),
1286 temp.path()
1287 .join(".agents/skills")
1288 .join("b")
1289 .join("SKILL.md"),
1290 temp.path()
1291 .join(".opencode/skills")
1292 .join("c")
1293 .join("SKILL.md"),
1294 temp.path()
1295 .join(".claude/skills")
1296 .join("d")
1297 .join("SKILL.md"),
1298 temp.path().join("SKILL.md"),
1299 ];
1300
1301 for path in &skill_paths {
1302 fs::create_dir_all(path.parent().unwrap()).unwrap();
1303 }
1304
1305 fs::write(
1306 &skill_paths[0],
1307 "---\nname: skill-a\ndescription: from skills\n---\n",
1308 )
1309 .unwrap();
1310 fs::write(
1311 &skill_paths[1],
1312 "---\nname: skill-b\ndescription: from agents\n---\n",
1313 )
1314 .unwrap();
1315 fs::write(
1316 &skill_paths[2],
1317 "---\nname: skill-c\ndescription: from opencode\n---\n",
1318 )
1319 .unwrap();
1320 fs::write(
1321 &skill_paths[3],
1322 "---\nname: skill-d\ndescription: from claude\n---\n",
1323 )
1324 .unwrap();
1325 fs::write(
1326 &skill_paths[4],
1327 "---\nname: root-skill\ndescription: from root\n---\n",
1328 )
1329 .unwrap();
1330
1331 let discovered = discover_skills_from_path(temp.path());
1332 let mut names = discovered.into_iter().map(|s| s.name).collect::<Vec<_>>();
1333 names.sort();
1334 assert_eq!(
1335 names,
1336 vec![
1337 "root-skill".to_string(),
1338 "skill-a".to_string(),
1339 "skill-b".to_string(),
1340 "skill-c".to_string(),
1341 "skill-d".to_string()
1342 ]
1343 );
1344 }
1345
1346 #[test]
1347 fn branch_to_safe_dir_name_replaces_unsafe_chars() {
1348 assert_eq!(
1349 branch_to_safe_dir_name("feature/new ui@2026"),
1350 "feature-new-ui-2026"
1351 );
1352 assert_eq!(branch_to_safe_dir_name("release-1.2.3"), "release-1.2.3");
1353 }
1354
1355 #[test]
1356 fn compute_historical_related_files_collects_cochange_context() {
1357 let temp = tempdir().unwrap();
1358 let repo = temp.path();
1359
1360 git(repo, &["init", "-b", "main"]);
1361 git(repo, &["config", "--local", "user.name", "Routa Test"]);
1363 git(
1364 repo,
1365 &["config", "--local", "user.email", "test@example.com"],
1366 );
1367
1368 fs::write(
1369 repo.join("example.ts"),
1370 "import { helper } from './helper';\nexport const value = helper(1);\nexport const trailing = 'stable';\n",
1371 )
1372 .unwrap();
1373 fs::write(
1374 repo.join("helper.ts"),
1375 "export function helper(input: number): number {\n return input + 1;\n}\n",
1376 )
1377 .unwrap();
1378 git(repo, &["add", "."]);
1379 git(
1380 repo,
1381 &[
1382 "-c",
1383 "commit.gpgSign=false",
1384 "commit",
1385 "-m",
1386 "initial shared context",
1387 ],
1388 );
1389
1390 fs::write(
1391 repo.join("example.ts"),
1392 "import { helper } from './helper';\nexport const value = helper(2);\nexport const trailing = 'stable';\n",
1393 )
1394 .unwrap();
1395 git(repo, &["add", "example.ts"]);
1396 git(
1397 repo,
1398 &[
1399 "-c",
1400 "commit.gpgSign=false",
1401 "commit",
1402 "-m",
1403 "update example only",
1404 ],
1405 );
1406
1407 let related = compute_historical_related_files(repo, "HEAD~1..HEAD", "HEAD", 20).unwrap();
1408 assert!(!related.is_empty());
1409
1410 let mut unique_paths = HashSet::new();
1411 for item in &related {
1412 assert!(unique_paths.insert(item.path.clone()));
1413 assert!(item.score > 0.0);
1414 assert!(!item.source_files.is_empty());
1415 assert!(!item.related_commits.is_empty());
1416 }
1417
1418 let helper = related
1419 .iter()
1420 .find(|item| item.path == "helper.ts")
1421 .expect("helper.ts should be suggested");
1422 assert_eq!(helper.source_files, vec!["example.ts".to_string()]);
1423 assert_eq!(helper.related_commits.len(), 1);
1424 }
1425
1426 #[test]
1427 fn compute_historical_related_files_handles_deleted_files_without_failing() {
1428 let temp = tempdir().unwrap();
1429 let repo = temp.path();
1430
1431 git(repo, &["init", "-b", "main"]);
1432 git(repo, &["config", "user.name", "Routa Test"]);
1433 git(repo, &["config", "user.email", "test@example.com"]);
1434
1435 fs::write(repo.join("keep.rs"), "pub fn keep() {}\n").unwrap();
1436 fs::write(repo.join("drop.rs"), "pub fn drop() {}\n").unwrap();
1437 git(repo, &["add", "."]);
1438 git(
1439 repo,
1440 &["-c", "commit.gpgSign=false", "commit", "-m", "initial"],
1441 );
1442
1443 fs::write(
1444 repo.join("keep.rs"),
1445 "pub fn keep() { println!(\"keep\"); }\n",
1446 )
1447 .unwrap();
1448 fs::remove_file(repo.join("drop.rs")).unwrap();
1449 git(repo, &["add", "-A"]);
1450 git(
1451 repo,
1452 &["-c", "commit.gpgSign=false", "commit", "-m", "delete drop"],
1453 );
1454
1455 let related = compute_historical_related_files(repo, "HEAD~1..HEAD", "HEAD", 20).unwrap();
1456 assert!(related.is_empty());
1457 }
1458
1459 #[test]
1460 fn copy_dir_recursive_skips_git_and_node_modules() {
1461 let temp = tempdir().unwrap();
1462 let src = temp.path().join("src");
1463 let dest = temp.path().join("dest");
1464
1465 fs::create_dir_all(src.join(".git")).unwrap();
1466 fs::create_dir_all(src.join("node_modules/pkg")).unwrap();
1467 fs::create_dir_all(src.join("nested")).unwrap();
1468
1469 fs::write(src.join(".git/config"), "ignored").unwrap();
1470 fs::write(src.join("node_modules/pkg/index.js"), "ignored").unwrap();
1471 fs::write(src.join("nested/kept.txt"), "hello").unwrap();
1472 fs::write(src.join("root.txt"), "root").unwrap();
1473
1474 copy_dir_recursive(&src, &dest).unwrap();
1475
1476 assert!(dest.join("root.txt").is_file());
1477 assert!(dest.join("nested/kept.txt").is_file());
1478 assert!(!dest.join(".git").exists());
1479 assert!(!dest.join("node_modules").exists());
1480 }
1481}