Skip to main content

tmai_core/git/
mod.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3use tokio::process::Command;
4
5/// Timeout for git commands to prevent hanging on unresponsive repos
6const GIT_TIMEOUT: Duration = Duration::from_secs(5);
7
8/// Git information for a working directory
9#[derive(Debug, Clone)]
10pub struct GitInfo {
11    /// Current branch name
12    pub branch: String,
13    /// Whether the working tree has uncommitted changes
14    pub dirty: bool,
15    /// Whether this directory is a git worktree (not the main repo)
16    pub is_worktree: bool,
17    /// Absolute path to the shared git common directory (same as .git dir for main repo)
18    pub common_dir: Option<String>,
19}
20
21/// Cache for git information with TTL
22pub 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    /// Create a new GitCache with default TTL of 10 seconds
35    pub fn new() -> Self {
36        Self {
37            cache: HashMap::new(),
38            ttl_secs: 10,
39        }
40    }
41
42    /// Get git info for a directory, using cache if available.
43    /// Fetches fresh info from git if cache is expired or missing.
44    pub async fn get_info(&mut self, dir: &str) -> Option<GitInfo> {
45        // Check cache (includes negative cache for non-git directories)
46        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        // Fetch fresh info
53        let info = fetch_git_info(dir).await;
54        self.cache
55            .insert(dir.to_string(), (info.clone(), Instant::now()));
56        info
57    }
58
59    /// Get cached git info without fetching from git.
60    /// Returns None if no cached entry exists or cache is expired.
61    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    /// Remove expired entries from cache
71    pub fn cleanup(&mut self) {
72        self.cache
73            .retain(|_, (_, ts)| ts.elapsed().as_secs() < self.ttl_secs * 3);
74    }
75}
76
77/// Fetch all git info for a directory (branch, dirty, worktree) with timeout
78async fn fetch_git_info(dir: &str) -> Option<GitInfo> {
79    let branch = fetch_branch(dir).await?;
80    // Run dirty and worktree checks in parallel
81    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
91/// Fetch the current branch name for a directory
92async 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
109/// Check if the working tree has uncommitted changes
110async 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
124/// Check if the directory is a git worktree and return the common dir
125///
126/// Returns `(is_worktree, common_dir)` where `common_dir` is the absolute
127/// path to the shared git directory. For worktrees this differs from git-dir;
128/// for the main repo they are the same.
129async 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            // Resolve common_dir to absolute path (git may return relative like ".")
151            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/// Parsed worktree entry from `git worktree list --porcelain`
164#[derive(Debug, Clone)]
165pub struct WorktreeEntry {
166    /// Absolute path to the worktree
167    pub path: String,
168    /// Branch name (None for detached HEAD)
169    pub branch: Option<String>,
170    /// Whether this is a bare repository
171    pub is_bare: bool,
172    /// Whether this is the main working tree (first entry)
173    pub is_main: bool,
174}
175
176/// List all worktrees for a repository by running `git worktree list --porcelain`
177pub 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
191/// Parse `git worktree list --porcelain` output into WorktreeEntry values
192fn 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            // Flush previous entry
202            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            // Extract branch name from refs/heads/xxx
216            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            // detached HEAD: branch stays None
226        }
227        // Empty lines separate entries but we handle via "worktree" prefix
228    }
229
230    // Flush last entry
231    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/// Diff statistics summary (files changed, insertions, deletions)
244#[derive(Debug, Clone, Default)]
245pub struct DiffSummary {
246    /// Number of files changed
247    pub files_changed: usize,
248    /// Number of lines inserted
249    pub insertions: usize,
250    /// Number of lines deleted
251    pub deletions: usize,
252}
253
254/// Fetch lightweight diff statistics between base branch and HEAD
255///
256/// Runs `git diff --shortstat <base>...HEAD` and parses the output.
257/// Returns None if the command fails or no diff exists.
258pub 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
281/// Fetch diff statistics between two explicit branches (not using HEAD)
282pub 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
309/// Parse `git diff --shortstat` output into DiffSummary
310///
311/// Example input: " 3 files changed, 45 insertions(+), 12 deletions(-)\n"
312fn 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        // Extract the leading number
323        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
338/// Fetch full diff content between base branch and HEAD (on-demand)
339///
340/// Runs `git diff <base>...HEAD --stat --patch` and truncates at 100KB.
341/// Returns None if the command fails or produces no output.
342pub 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    // Truncate at 100KB to prevent memory issues with large diffs
367    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/// Remote tracking info for a branch
378#[derive(Debug, Clone, serde::Serialize)]
379pub struct RemoteTrackingInfo {
380    /// Remote tracking branch name (e.g., "origin/main")
381    pub remote_branch: String,
382    /// Commits ahead of remote (need to push)
383    pub ahead: usize,
384    /// Commits behind remote (need to pull)
385    pub behind: usize,
386}
387
388/// Result of listing branches for a repository
389#[derive(Debug, Clone, serde::Serialize)]
390pub struct BranchListResult {
391    /// Detected default branch (main, master, etc.)
392    pub default_branch: String,
393    /// Currently checked-out branch (HEAD)
394    pub current_branch: Option<String>,
395    /// All local branch names
396    pub branches: Vec<String>,
397    /// Parent branch map: branch_name → closest ancestor branch
398    #[serde(default)]
399    pub parents: HashMap<String, String>,
400    /// Ahead/behind counts vs default branch: branch_name → (ahead, behind)
401    #[serde(default)]
402    pub ahead_behind: HashMap<String, (usize, usize)>,
403    /// Remote tracking info per branch
404    #[serde(default)]
405    pub remote_tracking: HashMap<String, RemoteTrackingInfo>,
406    /// Remote-only branches (no local counterpart), e.g., "origin/fix-hook-script"
407    #[serde(default)]
408    pub remote_only_branches: Vec<String>,
409    /// Last fetch timestamp (Unix seconds), None if never fetched
410    pub last_fetch: Option<u64>,
411}
412
413/// List branches for a repository and detect the default branch
414pub async fn list_branches(repo_dir: &str) -> Option<BranchListResult> {
415    // List local branches
416    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    // Detect default branch: try symbolic-ref, then fallback to main/master
437    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    // Get current HEAD branch
451    let current_branch = fetch_branch(repo_dir).await;
452
453    // Compute parent branch map
454    let parents = compute_branch_parents(repo_dir, &branches, &default_branch).await;
455
456    // Compute ahead/behind vs default branch for each branch
457    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    // Compute remote tracking info
468    let remote_tracking = fetch_remote_tracking(repo_dir).await;
469
470    // Get remote-only branches (no local counterpart)
471    let remote_only_branches = fetch_remote_only_branches(repo_dir, &branches).await;
472
473    // Get last fetch timestamp
474    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
488/// Fetch remote tracking info for all local branches
489///
490/// Uses `git for-each-ref` to get upstream tracking and ahead/behind counts.
491async 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        // Parse track: e.g., "[ahead 3]", "[behind 2]", "[ahead 3, behind 2]"
531        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
546/// Fetch remote branches that have no local counterpart
547///
548/// Returns short names like "origin/fix-hook-script", excluding HEAD and
549/// branches that match any local branch name.
550async 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            // Extract short name after "origin/" (or any remote prefix)
579            let short = s.split('/').skip(1).collect::<Vec<_>>().join("/");
580            !local_set.contains(short.as_str())
581        })
582        .collect()
583}
584
585/// Parse git upstream:track format
586///
587/// Examples: "[ahead 3]", "[behind 2]", "[ahead 3, behind 2]", "[gone]", ""
588fn parse_track(track: &str) -> (usize, usize) {
589    let mut ahead = 0usize;
590    let mut behind = 0usize;
591
592    // Strip brackets
593    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
610/// Get FETCH_HEAD modification time as Unix timestamp
611fn 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
621/// Compute parent branch for each non-default branch
622///
623/// Strategy: first check reflog for "Created from <branch>" (exact match),
624/// then fall back to closest ancestor via `git merge-base --is-ancestor`.
625async fn compute_branch_parents(
626    repo_dir: &str,
627    branches: &[String],
628    default_branch: &str,
629) -> HashMap<String, String> {
630    // Skip expensive O(n^2) ancestor computation when branch count is too high
631    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        // 1. Try reflog: "branch: Created from <name>"
644        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        // 2. Fallback: closest ancestor by commit count
652        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            // Check if candidate is a genuine parent (not a child branch).
684            // If merge-base(candidate, branch) == candidate HEAD, then candidate
685            // is entirely contained in branch's history — it's a child, not a parent.
686            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                    // candidate HEAD is the merge-base → candidate is behind branch
691                    continue;
692                }
693            }
694
695            // Count how far branch has diverged from candidate
696            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
716/// Check reflog for the branch creation source
717///
718/// Parses the last reflog entry for "Created from <branch_name>".
719/// When source is "HEAD", resolves by finding which known branch was
720/// at the same commit using `git branch --points-at`.
721/// Run a git command and return trimmed stdout, or None on failure/timeout
722async 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    // Format: "<sha> branch: Created from <source>"
762    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 HEAD: find which known branch was at the same commit
768        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
776/// Find which known branch was at a given commit
777///
778/// Uses `git branch --points-at <sha>` and picks the best match:
779/// default_branch preferred, otherwise first known branch found.
780async 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    // Prefer default branch among candidates
820    if candidates.contains(&default_branch) {
821        Some(default_branch.to_string())
822    } else {
823        Some(candidates[0].to_string())
824    }
825}
826
827/// Detect the default remote branch via symbolic-ref
828async 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    // "origin/main" -> "main"
851    refname
852        .strip_prefix("origin/")
853        .map(|s| s.to_string())
854        .or(Some(refname))
855        .filter(|s| !s.is_empty())
856}
857
858/// Validate a worktree name (alphanumeric, hyphens, and underscores only, max 64 chars)
859///
860/// Slashes are rejected: use flat names (`feature-auth`) for the directory,
861/// and let the branch name use a prefix (`worktree-feature-auth`).
862pub 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
870/// Extract worktree name from a `.claude/worktrees/{name}` path segment
871///
872/// Claude Code creates worktrees under `<repo>/.claude/worktrees/<name>/`.
873/// This function extracts `<name>` if the cwd contains that pattern.
874pub 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    // Take up to the next '/' or end of string
879    let name = after.split('/').next().filter(|s| !s.is_empty())?;
880    Some(name.to_string())
881}
882
883/// A single commit entry from git log
884#[derive(Debug, Clone, serde::Serialize)]
885pub struct CommitEntry {
886    pub sha: String,
887    pub subject: String,
888    pub body: String,
889}
890
891/// Get commit log between two branches (base..branch)
892///
893/// Returns list of CommitEntry with sha, subject, and full body.
894pub 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    // Use record separator (ASCII 0x1E) to split commits
905    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/// A single commit in the full graph (all branches)
942#[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/// Full graph data for lane-based visualization
952#[derive(Debug, Clone, serde::Serialize)]
953pub struct GraphData {
954    pub commits: Vec<GraphCommit>,
955    /// Total commit count across all branches (independent of max_commits limit)
956    pub total_count: usize,
957}
958
959/// Get full commit graph across all branches for lane-based visualization
960///
961/// Uses `git log --all --topo-order` to get commits from all branches
962/// with parent SHAs, ref decorations, and timestamps.
963pub 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    // Get total commit count (fast, no log parsing)
1022    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
1045/// Strip `/.git` or `/.git/` suffix from a path to get the repository root
1046///
1047/// Returns the original path if no `.git` suffix is found.
1048pub 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
1054/// Validate a git ref name (branch/tag) for safe use as a command argument
1055///
1056/// Rejects refs starting with `-` (could be misinterpreted as CLI flags)
1057/// and empty strings.
1058pub fn is_safe_git_ref(name: &str) -> bool {
1059    !name.is_empty() && !name.starts_with('-')
1060}
1061
1062/// Extract repository name from a git common directory path
1063///
1064/// Strips the trailing `/.git` suffix and returns the last path component.
1065/// Falls back to the full path if parsing fails.
1066pub 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
1080/// Delete a local branch
1081///
1082/// Uses `git branch -d` (safe delete, requires branch to be merged).
1083/// With `force=true`, uses `git branch -D` (force delete).
1084/// With `delete_remote=true`, also deletes the remote tracking branch (best-effort).
1085/// Returns Ok(()) on success, Err(message) on failure.
1086pub 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    // Best-effort: delete the remote tracking branch only when opted in
1113    if delete_remote {
1114        delete_remote_branch(repo_dir, branch).await;
1115    }
1116
1117    Ok(())
1118}
1119
1120/// Delete a remote tracking branch (best-effort, never fails the caller).
1121///
1122/// Runs `git push origin --delete <branch>`. Silently ignores errors
1123/// (e.g., no remote, branch not pushed, network issues).
1124async 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
1134/// Checkout (switch to) a local branch
1135///
1136/// Uses `git checkout <branch>`. Fails if there are uncommitted changes
1137/// that conflict with the target branch.
1138pub 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
1161/// Create a new local branch (without checking it out)
1162///
1163/// Uses `git branch <name> [base]`.
1164pub 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
1192/// Fetch from a remote (default: origin)
1193pub 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), // fetch can be slow
1201        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        // git fetch outputs to stderr even on success
1214        Ok(format!("{}{}", stdout.trim(), stderr.trim()))
1215    } else {
1216        Err(stderr.trim().to_string())
1217    }
1218}
1219
1220/// Pull from upstream (fetch + merge)
1221pub 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
1244/// Merge a branch into the current branch
1245pub 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
1272/// Get ahead/behind counts relative to a base branch
1273///
1274/// Returns (ahead, behind) commit counts.
1275pub 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        // Trailing slash with nothing after name marker
1336        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        // Fallback: just take last component
1357        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        // Valid names
1481        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        // Invalid: empty
1487        assert!(!is_valid_worktree_name(""));
1488
1489        // Invalid: special characters (command injection vectors)
1490        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        // Invalid: path traversal and slashes
1497        assert!(!is_valid_worktree_name("../../../etc"));
1498        assert!(!is_valid_worktree_name("foo/bar"));
1499
1500        // Invalid: spaces
1501        assert!(!is_valid_worktree_name("foo bar"));
1502
1503        // Invalid: too long (>64 chars)
1504        assert!(!is_valid_worktree_name(&"a".repeat(65)));
1505
1506        // Valid: exactly 64 chars
1507        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        // Use this repo itself as test subject
1537        let repo = env!("CARGO_MANIFEST_DIR");
1538        let result = log_graph(repo, 10).await;
1539        // Should succeed (we're in a git repo)
1540        assert!(result.is_some());
1541        let data = result.unwrap();
1542        assert!(!data.commits.is_empty());
1543        // First commit should have a SHA
1544        assert!(!data.commits[0].sha.is_empty());
1545        // authored_date should be non-zero (valid timestamp)
1546        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}