1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3use tokio::process::Command;
4
5const GIT_TIMEOUT: Duration = Duration::from_secs(5);
7
8#[derive(Debug, Clone)]
10pub struct GitInfo {
11 pub branch: String,
13 pub dirty: bool,
15 pub is_worktree: bool,
17 pub common_dir: Option<String>,
19}
20
21pub struct GitCache {
23 cache: HashMap<String, (Option<GitInfo>, Instant)>,
24 ttl_secs: u64,
25}
26
27impl Default for GitCache {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33impl GitCache {
34 pub fn new() -> Self {
36 Self {
37 cache: HashMap::new(),
38 ttl_secs: 10,
39 }
40 }
41
42 pub async fn get_info(&mut self, dir: &str) -> Option<GitInfo> {
45 if let Some((info, ts)) = self.cache.get(dir) {
47 if ts.elapsed().as_secs() < self.ttl_secs {
48 return info.clone();
49 }
50 }
51
52 let info = fetch_git_info(dir).await;
54 self.cache
55 .insert(dir.to_string(), (info.clone(), Instant::now()));
56 info
57 }
58
59 pub fn get_cached(&self, dir: &str) -> Option<GitInfo> {
62 if let Some((info, ts)) = self.cache.get(dir) {
63 if ts.elapsed().as_secs() < self.ttl_secs {
64 return info.clone();
65 }
66 }
67 None
68 }
69
70 pub fn cleanup(&mut self) {
72 self.cache
73 .retain(|_, (_, ts)| ts.elapsed().as_secs() < self.ttl_secs * 3);
74 }
75}
76
77async fn fetch_git_info(dir: &str) -> Option<GitInfo> {
79 let branch = fetch_branch(dir).await?;
80 let (dirty, (is_worktree, common_dir)) =
82 tokio::join!(fetch_dirty(dir), fetch_worktree_info(dir));
83 Some(GitInfo {
84 branch,
85 dirty,
86 is_worktree,
87 common_dir,
88 })
89}
90
91async fn fetch_branch(dir: &str) -> Option<String> {
93 let output = tokio::time::timeout(
94 GIT_TIMEOUT,
95 Command::new("git")
96 .args(["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"])
97 .output(),
98 )
99 .await
100 .ok()?
101 .ok()?;
102 if output.status.success() {
103 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
104 } else {
105 None
106 }
107}
108
109async fn fetch_dirty(dir: &str) -> bool {
111 let output = tokio::time::timeout(
112 GIT_TIMEOUT,
113 Command::new("git")
114 .args(["-C", dir, "status", "--porcelain"])
115 .output(),
116 )
117 .await;
118 match output {
119 Ok(Ok(o)) => !o.stdout.is_empty(),
120 _ => false,
121 }
122}
123
124async fn fetch_worktree_info(dir: &str) -> (bool, Option<String>) {
130 let results = tokio::join!(
131 tokio::time::timeout(
132 GIT_TIMEOUT,
133 Command::new("git")
134 .args(["-C", dir, "rev-parse", "--git-dir"])
135 .output(),
136 ),
137 tokio::time::timeout(
138 GIT_TIMEOUT,
139 Command::new("git")
140 .args(["-C", dir, "rev-parse", "--git-common-dir"])
141 .output(),
142 ),
143 );
144 match results {
145 (Ok(Ok(gd)), Ok(Ok(cd))) => {
146 let gd_str = String::from_utf8_lossy(&gd.stdout).trim().to_string();
147 let cd_str = String::from_utf8_lossy(&cd.stdout).trim().to_string();
148 let is_worktree = gd_str != cd_str;
149
150 let common_dir_path = std::path::Path::new(dir).join(&cd_str);
152 let common_dir = common_dir_path
153 .canonicalize()
154 .ok()
155 .map(|p| p.to_string_lossy().to_string());
156
157 (is_worktree, common_dir)
158 }
159 _ => (false, None),
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct WorktreeEntry {
166 pub path: String,
168 pub branch: Option<String>,
170 pub is_bare: bool,
172 pub is_main: bool,
174}
175
176pub async fn list_worktrees(repo_dir: &str) -> Vec<WorktreeEntry> {
178 let output = tokio::time::timeout(
179 GIT_TIMEOUT,
180 Command::new("git")
181 .args(["-C", repo_dir, "worktree", "list", "--porcelain"])
182 .output(),
183 )
184 .await;
185 match output {
186 Ok(Ok(o)) if o.status.success() => parse_worktree_list(&String::from_utf8_lossy(&o.stdout)),
187 _ => Vec::new(),
188 }
189}
190
191fn parse_worktree_list(output: &str) -> Vec<WorktreeEntry> {
193 let mut entries = Vec::new();
194 let mut current_path: Option<String> = None;
195 let mut current_branch: Option<String> = None;
196 let mut is_bare = false;
197 let mut is_first = true;
198
199 for line in output.lines() {
200 if let Some(path) = line.strip_prefix("worktree ") {
201 if let Some(prev_path) = current_path.take() {
203 entries.push(WorktreeEntry {
204 path: prev_path,
205 branch: current_branch.take(),
206 is_bare,
207 is_main: entries.is_empty() && is_first,
208 });
209 is_first = false;
210 }
211 current_path = Some(path.to_string());
212 current_branch = None;
213 is_bare = false;
214 } else if let Some(branch_ref) = line.strip_prefix("branch ") {
215 current_branch = Some(
217 branch_ref
218 .strip_prefix("refs/heads/")
219 .unwrap_or(branch_ref)
220 .to_string(),
221 );
222 } else if line == "bare" {
223 is_bare = true;
224 } else if line == "detached" {
225 }
227 }
229
230 if let Some(path) = current_path.take() {
232 entries.push(WorktreeEntry {
233 path,
234 branch: current_branch.take(),
235 is_bare,
236 is_main: entries.is_empty() && is_first,
237 });
238 }
239
240 entries
241}
242
243#[derive(Debug, Clone, Default)]
245pub struct DiffSummary {
246 pub files_changed: usize,
248 pub insertions: usize,
250 pub deletions: usize,
252}
253
254pub async fn fetch_diff_stat(dir: &str, base_branch: &str) -> Option<DiffSummary> {
259 if !is_safe_git_ref(base_branch) {
260 return None;
261 }
262 let diff_spec = format!("{}...HEAD", base_branch);
263 let output = tokio::time::timeout(
264 GIT_TIMEOUT,
265 Command::new("git")
266 .args(["-C", dir, "diff", "--shortstat", &diff_spec])
267 .output(),
268 )
269 .await
270 .ok()?
271 .ok()?;
272
273 if !output.status.success() {
274 return None;
275 }
276
277 let text = String::from_utf8_lossy(&output.stdout);
278 parse_shortstat(&text)
279}
280
281pub async fn fetch_branch_diff_stat(
283 dir: &str,
284 branch: &str,
285 base_branch: &str,
286) -> Option<DiffSummary> {
287 if !is_safe_git_ref(base_branch) || !is_safe_git_ref(branch) {
288 return None;
289 }
290 let diff_spec = format!("{}...{}", base_branch, branch);
291 let output = tokio::time::timeout(
292 GIT_TIMEOUT,
293 Command::new("git")
294 .args(["-C", dir, "diff", "--shortstat", &diff_spec])
295 .output(),
296 )
297 .await
298 .ok()?
299 .ok()?;
300
301 if !output.status.success() {
302 return None;
303 }
304
305 let text = String::from_utf8_lossy(&output.stdout);
306 parse_shortstat(&text)
307}
308
309fn parse_shortstat(text: &str) -> Option<DiffSummary> {
313 let text = text.trim();
314 if text.is_empty() {
315 return None;
316 }
317
318 let mut summary = DiffSummary::default();
319
320 for part in text.split(',') {
321 let part = part.trim();
322 let num_str: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
324 let num: usize = num_str.parse().unwrap_or(0);
325
326 if part.contains("file") {
327 summary.files_changed = num;
328 } else if part.contains("insertion") {
329 summary.insertions = num;
330 } else if part.contains("deletion") {
331 summary.deletions = num;
332 }
333 }
334
335 Some(summary)
336}
337
338pub async fn fetch_full_diff(dir: &str, base_branch: &str) -> Option<String> {
343 if !is_safe_git_ref(base_branch) {
344 return None;
345 }
346 let diff_spec = format!("{}...HEAD", base_branch);
347 let output = tokio::time::timeout(
348 Duration::from_secs(10),
349 Command::new("git")
350 .args(["-C", dir, "diff", &diff_spec, "--stat", "--patch"])
351 .output(),
352 )
353 .await
354 .ok()?
355 .ok()?;
356
357 if !output.status.success() {
358 return None;
359 }
360
361 let text = String::from_utf8_lossy(&output.stdout).to_string();
362 if text.trim().is_empty() {
363 return None;
364 }
365
366 const MAX_DIFF_SIZE: usize = 100 * 1024;
368 if text.len() > MAX_DIFF_SIZE {
369 let mut truncated = text[..MAX_DIFF_SIZE].to_string();
370 truncated.push_str("\n\n... (diff truncated at 100KB) ...\n");
371 Some(truncated)
372 } else {
373 Some(text)
374 }
375}
376
377#[derive(Debug, Clone, serde::Serialize)]
379pub struct RemoteTrackingInfo {
380 pub remote_branch: String,
382 pub ahead: usize,
384 pub behind: usize,
386}
387
388#[derive(Debug, Clone, serde::Serialize)]
390pub struct BranchListResult {
391 pub default_branch: String,
393 pub current_branch: Option<String>,
395 pub branches: Vec<String>,
397 #[serde(default)]
399 pub parents: HashMap<String, String>,
400 #[serde(default)]
402 pub ahead_behind: HashMap<String, (usize, usize)>,
403 #[serde(default)]
405 pub remote_tracking: HashMap<String, RemoteTrackingInfo>,
406 #[serde(default)]
408 pub remote_only_branches: Vec<String>,
409 pub last_fetch: Option<u64>,
411}
412
413pub async fn list_branches(repo_dir: &str) -> Option<BranchListResult> {
415 let output = tokio::time::timeout(
417 GIT_TIMEOUT,
418 Command::new("git")
419 .args(["-C", repo_dir, "branch", "--format=%(refname:short)"])
420 .output(),
421 )
422 .await
423 .ok()?
424 .ok()?;
425
426 if !output.status.success() {
427 return None;
428 }
429
430 let branches: Vec<String> = String::from_utf8_lossy(&output.stdout)
431 .lines()
432 .map(|s| s.trim().to_string())
433 .filter(|s| !s.is_empty())
434 .collect();
435
436 let default_branch = detect_default_branch(repo_dir).await.unwrap_or_else(|| {
438 if branches.contains(&"main".to_string()) {
439 "main".to_string()
440 } else if branches.contains(&"master".to_string()) {
441 "master".to_string()
442 } else {
443 branches
444 .first()
445 .cloned()
446 .unwrap_or_else(|| "main".to_string())
447 }
448 });
449
450 let current_branch = fetch_branch(repo_dir).await;
452
453 let parents = compute_branch_parents(repo_dir, &branches, &default_branch).await;
455
456 let mut ab_map = HashMap::new();
458 for branch in &branches {
459 if branch == &default_branch {
460 continue;
461 }
462 if let Some((a, b)) = ahead_behind(repo_dir, branch, &default_branch).await {
463 ab_map.insert(branch.clone(), (a, b));
464 }
465 }
466
467 let remote_tracking = fetch_remote_tracking(repo_dir).await;
469
470 let remote_only_branches = fetch_remote_only_branches(repo_dir, &branches).await;
472
473 let last_fetch = fetch_head_time(repo_dir);
475
476 Some(BranchListResult {
477 default_branch,
478 current_branch,
479 branches,
480 parents,
481 ahead_behind: ab_map,
482 remote_tracking,
483 remote_only_branches,
484 last_fetch,
485 })
486}
487
488async fn fetch_remote_tracking(repo_dir: &str) -> HashMap<String, RemoteTrackingInfo> {
492 let output = tokio::time::timeout(
493 GIT_TIMEOUT,
494 Command::new("git")
495 .args([
496 "-C",
497 repo_dir,
498 "for-each-ref",
499 "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)",
500 "refs/heads/",
501 ])
502 .output(),
503 )
504 .await
505 .ok()
506 .and_then(|r| r.ok());
507
508 let mut result = HashMap::new();
509
510 let Some(output) = output else {
511 return result;
512 };
513 if !output.status.success() {
514 return result;
515 }
516
517 for line in String::from_utf8_lossy(&output.stdout).lines() {
518 let parts: Vec<&str> = line.split('\t').collect();
519 if parts.len() < 2 {
520 continue;
521 }
522 let branch = parts[0].trim();
523 let upstream = parts[1].trim();
524 let track = parts.get(2).map(|s| s.trim()).unwrap_or("");
525
526 if upstream.is_empty() {
527 continue;
528 }
529
530 let (ahead, behind) = parse_track(track);
532
533 result.insert(
534 branch.to_string(),
535 RemoteTrackingInfo {
536 remote_branch: upstream.to_string(),
537 ahead,
538 behind,
539 },
540 );
541 }
542
543 result
544}
545
546async fn fetch_remote_only_branches(repo_dir: &str, local_branches: &[String]) -> Vec<String> {
551 let output = tokio::time::timeout(
552 GIT_TIMEOUT,
553 Command::new("git")
554 .args(["-C", repo_dir, "branch", "-r", "--format=%(refname:short)"])
555 .output(),
556 )
557 .await
558 .ok()
559 .and_then(|r| r.ok());
560
561 let Some(output) = output else {
562 return Vec::new();
563 };
564 if !output.status.success() {
565 return Vec::new();
566 }
567
568 let local_set: std::collections::HashSet<&str> =
569 local_branches.iter().map(|s| s.as_str()).collect();
570
571 String::from_utf8_lossy(&output.stdout)
572 .lines()
573 .map(|s| s.trim().to_string())
574 .filter(|s| {
575 if s.is_empty() || s.contains("->") {
576 return false;
577 }
578 let short = s.split('/').skip(1).collect::<Vec<_>>().join("/");
580 !local_set.contains(short.as_str())
581 })
582 .collect()
583}
584
585fn parse_track(track: &str) -> (usize, usize) {
589 let mut ahead = 0usize;
590 let mut behind = 0usize;
591
592 let inner = track
594 .strip_prefix('[')
595 .and_then(|s| s.strip_suffix(']'))
596 .unwrap_or("");
597
598 for part in inner.split(',') {
599 let part = part.trim();
600 if let Some(n) = part.strip_prefix("ahead ") {
601 ahead = n.trim().parse().unwrap_or(0);
602 } else if let Some(n) = part.strip_prefix("behind ") {
603 behind = n.trim().parse().unwrap_or(0);
604 }
605 }
606
607 (ahead, behind)
608}
609
610fn fetch_head_time(repo_dir: &str) -> Option<u64> {
612 let fetch_head = std::path::Path::new(repo_dir).join(".git/FETCH_HEAD");
613 let meta = std::fs::metadata(fetch_head).ok()?;
614 let modified = meta.modified().ok()?;
615 modified
616 .duration_since(std::time::UNIX_EPOCH)
617 .ok()
618 .map(|d| d.as_secs())
619}
620
621async fn compute_branch_parents(
626 repo_dir: &str,
627 branches: &[String],
628 default_branch: &str,
629) -> HashMap<String, String> {
630 if branches.len() > 30 {
632 return HashMap::new();
633 }
634
635 let branch_set: std::collections::HashSet<&str> = branches.iter().map(|s| s.as_str()).collect();
636 let mut parents = HashMap::new();
637
638 for branch in branches {
639 if branch == default_branch {
640 continue;
641 }
642
643 if let Some(parent) =
645 reflog_created_from(repo_dir, branch, &branch_set, default_branch).await
646 {
647 parents.insert(branch.clone(), parent);
648 continue;
649 }
650
651 let mut best_parent = default_branch.to_string();
653 let mut best_count = u32::MAX;
654
655 for candidate in branches {
656 if candidate == branch {
657 continue;
658 }
659
660 let is_ancestor = tokio::time::timeout(
661 GIT_TIMEOUT,
662 Command::new("git")
663 .args([
664 "-C",
665 repo_dir,
666 "merge-base",
667 "--is-ancestor",
668 candidate,
669 branch,
670 ])
671 .output(),
672 )
673 .await
674 .ok()
675 .and_then(|r| r.ok())
676 .map(|o| o.status.success())
677 .unwrap_or(false);
678
679 if !is_ancestor {
680 continue;
681 }
682
683 let merge_base = git_output(repo_dir, &["merge-base", candidate, branch]).await;
687 let candidate_head = git_output(repo_dir, &["rev-parse", candidate]).await;
688 if let (Some(mb), Some(ch)) = (&merge_base, &candidate_head) {
689 if mb == ch {
690 continue;
692 }
693 }
694
695 let count = git_output(
697 repo_dir,
698 &["rev-list", "--count", &format!("{}..{}", candidate, branch)],
699 )
700 .await
701 .and_then(|s| s.parse::<u32>().ok())
702 .unwrap_or(u32::MAX);
703
704 if count < best_count {
705 best_count = count;
706 best_parent = candidate.clone();
707 }
708 }
709
710 parents.insert(branch.clone(), best_parent);
711 }
712
713 parents
714}
715
716async fn git_output(repo_dir: &str, args: &[&str]) -> Option<String> {
723 let mut cmd_args = vec!["-C", repo_dir];
724 cmd_args.extend_from_slice(args);
725 tokio::time::timeout(GIT_TIMEOUT, Command::new("git").args(&cmd_args).output())
726 .await
727 .ok()
728 .and_then(|r| r.ok())
729 .and_then(|o| {
730 if o.status.success() {
731 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
732 } else {
733 None
734 }
735 })
736}
737
738async fn reflog_created_from(
739 repo_dir: &str,
740 branch: &str,
741 known_branches: &std::collections::HashSet<&str>,
742 default_branch: &str,
743) -> Option<String> {
744 let output = tokio::time::timeout(
745 GIT_TIMEOUT,
746 Command::new("git")
747 .args(["-C", repo_dir, "reflog", "show", branch, "--format=%H %gs"])
748 .output(),
749 )
750 .await
751 .ok()?
752 .ok()?;
753
754 if !output.status.success() {
755 return None;
756 }
757
758 let text = String::from_utf8_lossy(&output.stdout);
759 let last_line = text.lines().last()?;
760
761 let (sha, action) = last_line.split_once(' ')?;
763 let raw_source = action.strip_prefix("branch: Created from ")?.trim();
764 let source = raw_source.strip_prefix("refs/heads/").unwrap_or(raw_source);
765
766 if source == "HEAD" {
767 resolve_branch_at_commit(repo_dir, sha, branch, known_branches, default_branch).await
769 } else if known_branches.contains(source) {
770 Some(source.to_string())
771 } else {
772 None
773 }
774}
775
776async fn resolve_branch_at_commit(
781 repo_dir: &str,
782 sha: &str,
783 exclude_branch: &str,
784 known_branches: &std::collections::HashSet<&str>,
785 default_branch: &str,
786) -> Option<String> {
787 let output = tokio::time::timeout(
788 GIT_TIMEOUT,
789 Command::new("git")
790 .args([
791 "-C",
792 repo_dir,
793 "branch",
794 "--points-at",
795 sha,
796 "--format=%(refname:short)",
797 ])
798 .output(),
799 )
800 .await
801 .ok()?
802 .ok()?;
803
804 if !output.status.success() {
805 return None;
806 }
807
808 let text = String::from_utf8_lossy(&output.stdout);
809 let candidates: Vec<&str> = text
810 .lines()
811 .map(|l| l.trim())
812 .filter(|l| !l.is_empty() && *l != exclude_branch && known_branches.contains(l))
813 .collect();
814
815 if candidates.is_empty() {
816 return None;
817 }
818
819 if candidates.contains(&default_branch) {
821 Some(default_branch.to_string())
822 } else {
823 Some(candidates[0].to_string())
824 }
825}
826
827async fn detect_default_branch(repo_dir: &str) -> Option<String> {
829 let output = tokio::time::timeout(
830 Duration::from_secs(3),
831 Command::new("git")
832 .args([
833 "-C",
834 repo_dir,
835 "symbolic-ref",
836 "refs/remotes/origin/HEAD",
837 "--short",
838 ])
839 .output(),
840 )
841 .await
842 .ok()?
843 .ok()?;
844
845 if !output.status.success() {
846 return None;
847 }
848
849 let refname = String::from_utf8_lossy(&output.stdout).trim().to_string();
850 refname
852 .strip_prefix("origin/")
853 .map(|s| s.to_string())
854 .or(Some(refname))
855 .filter(|s| !s.is_empty())
856}
857
858pub fn is_valid_worktree_name(name: &str) -> bool {
863 !name.is_empty()
864 && name.len() <= 64
865 && name
866 .chars()
867 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
868}
869
870pub fn extract_claude_worktree_name(cwd: &str) -> Option<String> {
875 let marker = "/.claude/worktrees/";
876 let idx = cwd.find(marker)?;
877 let after = &cwd[idx + marker.len()..];
878 let name = after.split('/').next().filter(|s| !s.is_empty())?;
880 Some(name.to_string())
881}
882
883#[derive(Debug, Clone, serde::Serialize)]
885pub struct CommitEntry {
886 pub sha: String,
887 pub subject: String,
888 pub body: String,
889}
890
891pub async fn log_commits(
895 repo_dir: &str,
896 base: &str,
897 branch: &str,
898 max_count: usize,
899) -> Vec<CommitEntry> {
900 if !is_safe_git_ref(base) || !is_safe_git_ref(branch) {
901 return Vec::new();
902 }
903
904 let output = tokio::time::timeout(
906 GIT_TIMEOUT,
907 Command::new("git")
908 .args([
909 "-C",
910 repo_dir,
911 "log",
912 "--format=%h\t%s\t%b%x1e",
913 &format!("--max-count={}", max_count),
914 &format!("{}..{}", base, branch),
915 ])
916 .output(),
917 )
918 .await
919 .ok()
920 .and_then(|r| r.ok());
921
922 match output {
923 Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
924 .split('\x1e')
925 .filter_map(|entry| {
926 let entry = entry.trim();
927 if entry.is_empty() {
928 return None;
929 }
930 let mut parts = entry.splitn(3, '\t');
931 let sha = parts.next()?.trim().to_string();
932 let subject = parts.next()?.trim().to_string();
933 let body = parts.next().unwrap_or("").trim().to_string();
934 Some(CommitEntry { sha, subject, body })
935 })
936 .collect(),
937 _ => Vec::new(),
938 }
939}
940
941#[derive(Debug, Clone, serde::Serialize)]
943pub struct GraphCommit {
944 pub sha: String,
945 pub parents: Vec<String>,
946 pub refs: Vec<String>,
947 pub subject: String,
948 pub authored_date: i64,
949}
950
951#[derive(Debug, Clone, serde::Serialize)]
953pub struct GraphData {
954 pub commits: Vec<GraphCommit>,
955 pub total_count: usize,
957}
958
959pub async fn log_graph(repo_dir: &str, max_commits: usize) -> Option<GraphData> {
964 let output = tokio::time::timeout(
965 GIT_TIMEOUT,
966 Command::new("git")
967 .args([
968 "-C",
969 repo_dir,
970 "log",
971 "--all",
972 "--topo-order",
973 &format!("--max-count={}", max_commits),
974 "--format=%H\t%P\t%D\t%s\t%at",
975 ])
976 .output(),
977 )
978 .await
979 .ok()
980 .and_then(|r| r.ok())?;
981
982 if !output.status.success() {
983 return None;
984 }
985
986 let stdout = String::from_utf8_lossy(&output.stdout);
987 let commits: Vec<GraphCommit> = stdout
988 .lines()
989 .filter_map(|line| {
990 let line = line.trim();
991 if line.is_empty() {
992 return None;
993 }
994 let mut parts = line.splitn(5, '\t');
995 let sha = parts.next()?.to_string();
996 let parents: Vec<String> = parts
997 .next()
998 .unwrap_or("")
999 .split_whitespace()
1000 .map(|s| s.to_string())
1001 .collect();
1002 let refs: Vec<String> = parts
1003 .next()
1004 .unwrap_or("")
1005 .split(", ")
1006 .map(|s| s.trim().to_string())
1007 .filter(|s| !s.is_empty())
1008 .collect();
1009 let subject = parts.next().unwrap_or("").to_string();
1010 let authored_date = parts.next().unwrap_or("0").parse::<i64>().unwrap_or(0);
1011 Some(GraphCommit {
1012 sha,
1013 parents,
1014 refs,
1015 subject,
1016 authored_date,
1017 })
1018 })
1019 .collect();
1020
1021 let total_count = tokio::time::timeout(
1023 GIT_TIMEOUT,
1024 Command::new("git")
1025 .args(["-C", repo_dir, "rev-list", "--all", "--count"])
1026 .output(),
1027 )
1028 .await
1029 .ok()
1030 .and_then(|r| r.ok())
1031 .and_then(|o| {
1032 String::from_utf8_lossy(&o.stdout)
1033 .trim()
1034 .parse::<usize>()
1035 .ok()
1036 })
1037 .unwrap_or(commits.len());
1038
1039 Some(GraphData {
1040 commits,
1041 total_count,
1042 })
1043}
1044
1045pub fn strip_git_suffix(path: &str) -> &str {
1049 path.strip_suffix("/.git")
1050 .or_else(|| path.strip_suffix("/.git/"))
1051 .unwrap_or(path)
1052}
1053
1054pub fn is_safe_git_ref(name: &str) -> bool {
1059 !name.is_empty() && !name.starts_with('-')
1060}
1061
1062pub fn repo_name_from_common_dir(common_dir: &str) -> String {
1067 let stripped = common_dir
1068 .strip_suffix("/.git")
1069 .or_else(|| common_dir.strip_suffix("/.git/"))
1070 .unwrap_or(common_dir);
1071 let trimmed = stripped.trim_end_matches('/');
1072 trimmed
1073 .rsplit('/')
1074 .next()
1075 .filter(|s| !s.is_empty())
1076 .unwrap_or(trimmed)
1077 .to_string()
1078}
1079
1080pub async fn delete_branch(
1087 repo_dir: &str,
1088 branch: &str,
1089 force: bool,
1090 delete_remote: bool,
1091) -> Result<(), String> {
1092 if !is_safe_git_ref(branch) {
1093 return Err("Invalid branch name".to_string());
1094 }
1095
1096 let flag = if force { "-D" } else { "-d" };
1097 let output = tokio::time::timeout(
1098 GIT_TIMEOUT,
1099 Command::new("git")
1100 .args(["-C", repo_dir, "branch", flag, branch])
1101 .output(),
1102 )
1103 .await
1104 .map_err(|_| "Git command timed out".to_string())?
1105 .map_err(|e| format!("Failed to run git: {}", e))?;
1106
1107 if !output.status.success() {
1108 let stderr = String::from_utf8_lossy(&output.stderr);
1109 return Err(stderr.trim().to_string());
1110 }
1111
1112 if delete_remote {
1114 delete_remote_branch(repo_dir, branch).await;
1115 }
1116
1117 Ok(())
1118}
1119
1120async fn delete_remote_branch(repo_dir: &str, branch: &str) {
1125 let _ = tokio::time::timeout(
1126 GIT_TIMEOUT,
1127 Command::new("git")
1128 .args(["-C", repo_dir, "push", "origin", "--delete", branch])
1129 .output(),
1130 )
1131 .await;
1132}
1133
1134pub async fn checkout_branch(repo_dir: &str, branch: &str) -> Result<(), String> {
1139 if !is_safe_git_ref(branch) {
1140 return Err("Invalid branch name".to_string());
1141 }
1142
1143 let output = tokio::time::timeout(
1144 GIT_TIMEOUT,
1145 Command::new("git")
1146 .args(["-C", repo_dir, "checkout", branch])
1147 .output(),
1148 )
1149 .await
1150 .map_err(|_| "Git command timed out".to_string())?
1151 .map_err(|e| format!("Failed to run git: {}", e))?;
1152
1153 if output.status.success() {
1154 Ok(())
1155 } else {
1156 let stderr = String::from_utf8_lossy(&output.stderr);
1157 Err(stderr.trim().to_string())
1158 }
1159}
1160
1161pub async fn create_branch(repo_dir: &str, name: &str, base: Option<&str>) -> Result<(), String> {
1165 if !is_safe_git_ref(name) {
1166 return Err("Invalid branch name".to_string());
1167 }
1168 if let Some(b) = base {
1169 if !is_safe_git_ref(b) {
1170 return Err("Invalid base branch name".to_string());
1171 }
1172 }
1173
1174 let mut args = vec!["-C", repo_dir, "branch", name];
1175 if let Some(b) = base {
1176 args.push(b);
1177 }
1178
1179 let output = tokio::time::timeout(GIT_TIMEOUT, Command::new("git").args(&args).output())
1180 .await
1181 .map_err(|_| "Git command timed out".to_string())?
1182 .map_err(|e| format!("Failed to run git: {}", e))?;
1183
1184 if output.status.success() {
1185 Ok(())
1186 } else {
1187 let stderr = String::from_utf8_lossy(&output.stderr);
1188 Err(stderr.trim().to_string())
1189 }
1190}
1191
1192pub async fn fetch_remote(repo_dir: &str, remote: Option<&str>) -> Result<String, String> {
1194 let remote = remote.unwrap_or("origin");
1195 if !is_safe_git_ref(remote) {
1196 return Err("Invalid remote name".to_string());
1197 }
1198
1199 let output = tokio::time::timeout(
1200 Duration::from_secs(30), Command::new("git")
1202 .args(["-C", repo_dir, "fetch", remote, "--prune"])
1203 .output(),
1204 )
1205 .await
1206 .map_err(|_| "Git fetch timed out".to_string())?
1207 .map_err(|e| format!("Failed to run git: {}", e))?;
1208
1209 let stdout = String::from_utf8_lossy(&output.stdout);
1210 let stderr = String::from_utf8_lossy(&output.stderr);
1211
1212 if output.status.success() {
1213 Ok(format!("{}{}", stdout.trim(), stderr.trim()))
1215 } else {
1216 Err(stderr.trim().to_string())
1217 }
1218}
1219
1220pub async fn pull(repo_dir: &str) -> Result<String, String> {
1222 let output = tokio::time::timeout(
1223 Duration::from_secs(30),
1224 Command::new("git")
1225 .args(["-C", repo_dir, "pull", "--ff-only"])
1226 .output(),
1227 )
1228 .await
1229 .map_err(|_| "Git pull timed out".to_string())?
1230 .map_err(|e| format!("Failed to run git: {}", e))?;
1231
1232 let stdout = String::from_utf8_lossy(&output.stdout);
1233 let stderr = String::from_utf8_lossy(&output.stderr);
1234
1235 if output.status.success() {
1236 Ok(stdout.trim().to_string())
1237 } else {
1238 Err(format!("{}\n{}", stdout.trim(), stderr.trim())
1239 .trim()
1240 .to_string())
1241 }
1242}
1243
1244pub async fn merge_branch(repo_dir: &str, branch: &str) -> Result<String, String> {
1246 if !is_safe_git_ref(branch) {
1247 return Err("Invalid branch name".to_string());
1248 }
1249
1250 let output = tokio::time::timeout(
1251 Duration::from_secs(15),
1252 Command::new("git")
1253 .args(["-C", repo_dir, "merge", branch, "--no-edit"])
1254 .output(),
1255 )
1256 .await
1257 .map_err(|_| "Git merge timed out".to_string())?
1258 .map_err(|e| format!("Failed to run git: {}", e))?;
1259
1260 let stdout = String::from_utf8_lossy(&output.stdout);
1261 let stderr = String::from_utf8_lossy(&output.stderr);
1262
1263 if output.status.success() {
1264 Ok(stdout.trim().to_string())
1265 } else {
1266 Err(format!("{}\n{}", stdout.trim(), stderr.trim())
1267 .trim()
1268 .to_string())
1269 }
1270}
1271
1272pub async fn ahead_behind(repo_dir: &str, branch: &str, base: &str) -> Option<(usize, usize)> {
1276 if !is_safe_git_ref(branch) || !is_safe_git_ref(base) {
1277 return None;
1278 }
1279
1280 let output = tokio::time::timeout(
1281 GIT_TIMEOUT,
1282 Command::new("git")
1283 .args([
1284 "-C",
1285 repo_dir,
1286 "rev-list",
1287 "--left-right",
1288 "--count",
1289 &format!("{}...{}", base, branch),
1290 ])
1291 .output(),
1292 )
1293 .await
1294 .ok()?
1295 .ok()?;
1296
1297 if !output.status.success() {
1298 return None;
1299 }
1300
1301 let text = String::from_utf8_lossy(&output.stdout);
1302 let parts: Vec<&str> = text.trim().split('\t').collect();
1303 if parts.len() == 2 {
1304 let behind = parts[0].parse().ok()?;
1305 let ahead = parts[1].parse().ok()?;
1306 Some((ahead, behind))
1307 } else {
1308 None
1309 }
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314 use super::*;
1315
1316 #[test]
1317 fn test_extract_claude_worktree_name_valid() {
1318 assert_eq!(
1319 extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/feature-a"),
1320 Some("feature-a".to_string())
1321 );
1322 assert_eq!(
1323 extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/feature-a/src"),
1324 Some("feature-a".to_string())
1325 );
1326 }
1327
1328 #[test]
1329 fn test_extract_claude_worktree_name_invalid() {
1330 assert_eq!(extract_claude_worktree_name("/home/user/my-app"), None);
1331 assert_eq!(
1332 extract_claude_worktree_name("/home/user/my-app/.claude/"),
1333 None
1334 );
1335 assert_eq!(
1337 extract_claude_worktree_name("/home/user/my-app/.claude/worktrees/"),
1338 None
1339 );
1340 }
1341
1342 #[test]
1343 fn test_repo_name_from_common_dir() {
1344 assert_eq!(
1345 repo_name_from_common_dir("/home/user/my-app/.git"),
1346 "my-app"
1347 );
1348 assert_eq!(
1349 repo_name_from_common_dir("/home/user/my-app/.git/"),
1350 "my-app"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_repo_name_from_common_dir_no_git_suffix() {
1356 assert_eq!(repo_name_from_common_dir("/home/user/my-app"), "my-app");
1358 }
1359
1360 #[test]
1361 fn test_repo_name_from_common_dir_bare() {
1362 assert_eq!(repo_name_from_common_dir("my-repo/.git"), "my-repo");
1363 }
1364
1365 #[test]
1366 fn test_parse_worktree_list_normal() {
1367 let output = "\
1368worktree /home/user/my-app
1369HEAD abc123def456
1370branch refs/heads/main
1371
1372worktree /home/user/my-app/.claude/worktrees/feature-a
1373HEAD def456abc789
1374branch refs/heads/feature-a
1375
1376";
1377 let entries = parse_worktree_list(output);
1378 assert_eq!(entries.len(), 2);
1379
1380 assert_eq!(entries[0].path, "/home/user/my-app");
1381 assert_eq!(entries[0].branch.as_deref(), Some("main"));
1382 assert!(!entries[0].is_bare);
1383 assert!(entries[0].is_main);
1384
1385 assert_eq!(
1386 entries[1].path,
1387 "/home/user/my-app/.claude/worktrees/feature-a"
1388 );
1389 assert_eq!(entries[1].branch.as_deref(), Some("feature-a"));
1390 assert!(!entries[1].is_bare);
1391 assert!(!entries[1].is_main);
1392 }
1393
1394 #[test]
1395 fn test_parse_worktree_list_detached_head() {
1396 let output = "\
1397worktree /home/user/my-app
1398HEAD abc123
1399branch refs/heads/main
1400
1401worktree /home/user/my-app/.claude/worktrees/temp
1402HEAD def456
1403detached
1404
1405";
1406 let entries = parse_worktree_list(output);
1407 assert_eq!(entries.len(), 2);
1408 assert_eq!(entries[1].branch, None);
1409 assert!(!entries[1].is_main);
1410 }
1411
1412 #[test]
1413 fn test_parse_worktree_list_bare_repo() {
1414 let output = "\
1415worktree /home/user/bare-repo
1416HEAD abc123
1417bare
1418
1419";
1420 let entries = parse_worktree_list(output);
1421 assert_eq!(entries.len(), 1);
1422 assert!(entries[0].is_bare);
1423 assert!(entries[0].is_main);
1424 }
1425
1426 #[test]
1427 fn test_parse_worktree_list_empty() {
1428 let entries = parse_worktree_list("");
1429 assert!(entries.is_empty());
1430 }
1431
1432 #[test]
1433 fn test_parse_worktree_list_single() {
1434 let output = "\
1435worktree /home/user/project
1436HEAD abc123
1437branch refs/heads/main
1438";
1439 let entries = parse_worktree_list(output);
1440 assert_eq!(entries.len(), 1);
1441 assert!(entries[0].is_main);
1442 assert_eq!(entries[0].branch.as_deref(), Some("main"));
1443 }
1444
1445 #[test]
1446 fn test_parse_shortstat_normal() {
1447 let input = " 3 files changed, 45 insertions(+), 12 deletions(-)\n";
1448 let summary = parse_shortstat(input).unwrap();
1449 assert_eq!(summary.files_changed, 3);
1450 assert_eq!(summary.insertions, 45);
1451 assert_eq!(summary.deletions, 12);
1452 }
1453
1454 #[test]
1455 fn test_parse_shortstat_insertions_only() {
1456 let input = " 1 file changed, 10 insertions(+)\n";
1457 let summary = parse_shortstat(input).unwrap();
1458 assert_eq!(summary.files_changed, 1);
1459 assert_eq!(summary.insertions, 10);
1460 assert_eq!(summary.deletions, 0);
1461 }
1462
1463 #[test]
1464 fn test_parse_shortstat_deletions_only() {
1465 let input = " 2 files changed, 5 deletions(-)\n";
1466 let summary = parse_shortstat(input).unwrap();
1467 assert_eq!(summary.files_changed, 2);
1468 assert_eq!(summary.insertions, 0);
1469 assert_eq!(summary.deletions, 5);
1470 }
1471
1472 #[test]
1473 fn test_parse_shortstat_empty() {
1474 assert!(parse_shortstat("").is_none());
1475 assert!(parse_shortstat(" \n").is_none());
1476 }
1477
1478 #[test]
1479 fn test_is_valid_worktree_name() {
1480 assert!(is_valid_worktree_name("feature-auth"));
1482 assert!(is_valid_worktree_name("fix_bug_123"));
1483 assert!(is_valid_worktree_name("a"));
1484 assert!(is_valid_worktree_name("my-worktree"));
1485
1486 assert!(!is_valid_worktree_name(""));
1488
1489 assert!(!is_valid_worktree_name("foo; rm -rf /"));
1491 assert!(!is_valid_worktree_name("$(evil)"));
1492 assert!(!is_valid_worktree_name("foo`whoami`"));
1493 assert!(!is_valid_worktree_name("a|b"));
1494 assert!(!is_valid_worktree_name("a&b"));
1495
1496 assert!(!is_valid_worktree_name("../../../etc"));
1498 assert!(!is_valid_worktree_name("foo/bar"));
1499
1500 assert!(!is_valid_worktree_name("foo bar"));
1502
1503 assert!(!is_valid_worktree_name(&"a".repeat(65)));
1505
1506 assert!(is_valid_worktree_name(&"a".repeat(64)));
1508 }
1509
1510 #[test]
1511 fn test_strip_git_suffix() {
1512 assert_eq!(
1513 strip_git_suffix("/home/user/my-app/.git"),
1514 "/home/user/my-app"
1515 );
1516 assert_eq!(
1517 strip_git_suffix("/home/user/my-app/.git/"),
1518 "/home/user/my-app"
1519 );
1520 assert_eq!(strip_git_suffix("/home/user/my-app"), "/home/user/my-app");
1521 assert_eq!(strip_git_suffix(""), "");
1522 }
1523
1524 #[test]
1525 fn test_is_safe_git_ref() {
1526 assert!(is_safe_git_ref("main"));
1527 assert!(is_safe_git_ref("feature/auth"));
1528 assert!(is_safe_git_ref("v1.0"));
1529 assert!(!is_safe_git_ref(""));
1530 assert!(!is_safe_git_ref("-flag"));
1531 assert!(!is_safe_git_ref("--exec=evil"));
1532 }
1533
1534 #[tokio::test]
1535 async fn test_log_graph_returns_data_for_this_repo() {
1536 let repo = env!("CARGO_MANIFEST_DIR");
1538 let result = log_graph(repo, 10).await;
1539 assert!(result.is_some());
1541 let data = result.unwrap();
1542 assert!(!data.commits.is_empty());
1543 assert!(!data.commits[0].sha.is_empty());
1545 assert!(data.commits[0].authored_date > 0);
1547 }
1548
1549 #[tokio::test]
1550 async fn test_log_graph_invalid_dir_returns_none() {
1551 let result = log_graph("/nonexistent/path", 10).await;
1552 assert!(result.is_none());
1553 }
1554}