vibe_graph_git/
lib.rs

1//! Git fossilization helpers and real-time change detection.
2
3use std::path::{Path, PathBuf};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use git2::{Repository, Status, StatusOptions};
8use vibe_graph_core::{GitChangeKind, GitChangeSnapshot, GitFileChange, Snapshot};
9
10/// Abstraction describing how snapshots are persisted and retrieved.
11pub trait GitFossilStore {
12    /// Persist the provided snapshot into the fossil store.
13    fn commit_snapshot(&self, snapshot: &Snapshot) -> Result<()>;
14
15    /// Retrieve the latest snapshot if one exists.
16    fn get_latest_snapshot(&self) -> Result<Option<Snapshot>>;
17}
18
19/// Default filesystem-backed Git store.
20pub struct GitBackend {
21    /// Filesystem path to the repository managed by this backend.
22    pub repo_path: PathBuf,
23}
24
25impl GitBackend {
26    /// Construct a backend targeting the provided repository path.
27    pub fn new(repo_path: PathBuf) -> Self {
28        Self { repo_path }
29    }
30}
31
32impl GitFossilStore for GitBackend {
33    fn commit_snapshot(&self, _snapshot: &Snapshot) -> Result<()> {
34        // Placeholder for future git2/plumbing integration.
35        Ok(())
36    }
37
38    fn get_latest_snapshot(&self) -> Result<Option<Snapshot>> {
39        Ok(None)
40    }
41}
42
43// =============================================================================
44// Git Change Watcher
45// =============================================================================
46
47/// Configuration for the git watcher.
48#[derive(Debug, Clone)]
49pub struct GitWatcherConfig {
50    /// Minimum interval between polls (to avoid hammering the filesystem).
51    pub min_poll_interval: Duration,
52    /// Whether to include untracked files.
53    pub include_untracked: bool,
54    /// Whether to include ignored files.
55    pub include_ignored: bool,
56    /// Whether to recurse into submodules.
57    pub recurse_submodules: bool,
58}
59
60impl Default for GitWatcherConfig {
61    fn default() -> Self {
62        Self {
63            min_poll_interval: Duration::from_millis(500),
64            include_untracked: true,
65            include_ignored: false,
66            recurse_submodules: false,
67        }
68    }
69}
70
71/// Watches a git repository for changes.
72///
73/// Uses polling-based approach with git2 for efficient status checks.
74pub struct GitWatcher {
75    /// Path to the repository root.
76    repo_path: PathBuf,
77    /// Configuration.
78    config: GitWatcherConfig,
79    /// Last poll time.
80    last_poll: Option<Instant>,
81    /// Cached snapshot.
82    cached_snapshot: GitChangeSnapshot,
83}
84
85impl GitWatcher {
86    /// Create a new watcher for the given repository path.
87    pub fn new(repo_path: impl Into<PathBuf>) -> Self {
88        Self {
89            repo_path: repo_path.into(),
90            config: GitWatcherConfig::default(),
91            last_poll: None,
92            cached_snapshot: GitChangeSnapshot::default(),
93        }
94    }
95
96    /// Create with custom configuration.
97    pub fn with_config(repo_path: impl Into<PathBuf>, config: GitWatcherConfig) -> Self {
98        Self {
99            repo_path: repo_path.into(),
100            config,
101            last_poll: None,
102            cached_snapshot: GitChangeSnapshot::default(),
103        }
104    }
105
106    /// Get the repository path.
107    pub fn repo_path(&self) -> &Path {
108        &self.repo_path
109    }
110
111    /// Check if it's time to poll again.
112    pub fn should_poll(&self) -> bool {
113        match self.last_poll {
114            Some(last) => last.elapsed() >= self.config.min_poll_interval,
115            None => true,
116        }
117    }
118
119    /// Get the cached snapshot (may be stale).
120    pub fn cached_snapshot(&self) -> &GitChangeSnapshot {
121        &self.cached_snapshot
122    }
123
124    /// Poll for changes, returning the current snapshot.
125    ///
126    /// This is rate-limited by `min_poll_interval`. If called too frequently,
127    /// returns the cached snapshot.
128    pub fn poll(&mut self) -> Result<&GitChangeSnapshot> {
129        if !self.should_poll() {
130            return Ok(&self.cached_snapshot);
131        }
132
133        self.cached_snapshot = self.fetch_changes()?;
134        self.last_poll = Some(Instant::now());
135        Ok(&self.cached_snapshot)
136    }
137
138    /// Force fetch changes regardless of rate limiting.
139    pub fn force_poll(&mut self) -> Result<&GitChangeSnapshot> {
140        self.cached_snapshot = self.fetch_changes()?;
141        self.last_poll = Some(Instant::now());
142        Ok(&self.cached_snapshot)
143    }
144
145    /// Fetch current git status and convert to GitChangeSnapshot.
146    fn fetch_changes(&self) -> Result<GitChangeSnapshot> {
147        let repo = Repository::open(&self.repo_path)
148            .with_context(|| format!("Failed to open repository at {:?}", self.repo_path))?;
149
150        let mut opts = StatusOptions::new();
151        opts.include_untracked(self.config.include_untracked)
152            .include_ignored(self.config.include_ignored)
153            .recurse_untracked_dirs(true)
154            .exclude_submodules(true);
155
156        let statuses = repo
157            .statuses(Some(&mut opts))
158            .context("Failed to get repository status")?;
159
160        let mut changes = Vec::new();
161
162        for entry in statuses.iter() {
163            let path = match entry.path() {
164                Some(p) => PathBuf::from(p),
165                None => continue,
166            };
167
168            let status = entry.status();
169
170            // Map git2 status flags to our GitChangeKind
171            // Check staged changes first (index)
172            if status.contains(Status::INDEX_NEW) {
173                changes.push(GitFileChange {
174                    path: path.clone(),
175                    kind: GitChangeKind::Added,
176                    staged: true,
177                });
178            } else if status.contains(Status::INDEX_MODIFIED) {
179                changes.push(GitFileChange {
180                    path: path.clone(),
181                    kind: GitChangeKind::Modified,
182                    staged: true,
183                });
184            } else if status.contains(Status::INDEX_DELETED) {
185                changes.push(GitFileChange {
186                    path: path.clone(),
187                    kind: GitChangeKind::Deleted,
188                    staged: true,
189                });
190            } else if status.contains(Status::INDEX_RENAMED) {
191                changes.push(GitFileChange {
192                    path: path.clone(),
193                    kind: GitChangeKind::RenamedTo,
194                    staged: true,
195                });
196            }
197
198            // Check working directory changes (not yet staged)
199            if status.contains(Status::WT_NEW) {
200                changes.push(GitFileChange {
201                    path: path.clone(),
202                    kind: GitChangeKind::Untracked,
203                    staged: false,
204                });
205            } else if status.contains(Status::WT_MODIFIED) {
206                changes.push(GitFileChange {
207                    path: path.clone(),
208                    kind: GitChangeKind::Modified,
209                    staged: false,
210                });
211            } else if status.contains(Status::WT_DELETED) {
212                changes.push(GitFileChange {
213                    path: path.clone(),
214                    kind: GitChangeKind::Deleted,
215                    staged: false,
216                });
217            } else if status.contains(Status::WT_RENAMED) {
218                changes.push(GitFileChange {
219                    path: path.clone(),
220                    kind: GitChangeKind::RenamedTo,
221                    staged: false,
222                });
223            }
224        }
225
226        Ok(GitChangeSnapshot {
227            changes,
228            captured_at: Some(Instant::now()),
229        })
230    }
231}
232
233/// Quick helper to get current changes for a path.
234pub fn get_git_changes(repo_path: &Path) -> Result<GitChangeSnapshot> {
235    let mut watcher = GitWatcher::new(repo_path);
236    watcher.force_poll().cloned()
237}
238
239// =============================================================================
240// Git Command Execution
241// =============================================================================
242
243use git2::{IndexAddOption, Signature, Time};
244use serde::{Deserialize, Serialize};
245
246/// Result of a git add operation.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct GitAddResult {
249    /// Files that were staged.
250    pub staged_files: Vec<PathBuf>,
251    /// Number of files staged.
252    pub count: usize,
253}
254
255/// Result of a git commit operation.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct GitCommitResult {
258    /// The commit hash (SHA).
259    pub commit_id: String,
260    /// The commit message.
261    pub message: String,
262    /// Number of files in the commit.
263    pub file_count: usize,
264}
265
266/// Result of a git reset operation.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct GitResetResult {
269    /// Files that were unstaged.
270    pub unstaged_files: Vec<PathBuf>,
271    /// Number of files unstaged.
272    pub count: usize,
273}
274
275/// Branch information.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct GitBranch {
278    /// Branch name.
279    pub name: String,
280    /// Whether this is the current branch.
281    pub is_current: bool,
282    /// Whether this is a remote branch.
283    pub is_remote: bool,
284    /// Latest commit SHA on this branch.
285    pub commit_id: Option<String>,
286}
287
288/// Result of listing branches.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct GitBranchListResult {
291    /// All branches.
292    pub branches: Vec<GitBranch>,
293    /// Current branch name (if any).
294    pub current: Option<String>,
295}
296
297/// Commit log entry.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct GitLogEntry {
300    /// Commit SHA.
301    pub commit_id: String,
302    /// Short SHA (7 chars).
303    pub short_id: String,
304    /// Commit message.
305    pub message: String,
306    /// Author name.
307    pub author: String,
308    /// Author email.
309    pub author_email: String,
310    /// Unix timestamp.
311    pub timestamp: i64,
312}
313
314/// Result of git log operation.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct GitLogResult {
317    /// Commit entries.
318    pub commits: Vec<GitLogEntry>,
319}
320
321/// Result of git diff operation.
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct GitDiffResult {
324    /// The diff output as text.
325    pub diff: String,
326    /// Number of files changed.
327    pub files_changed: usize,
328    /// Lines added.
329    pub insertions: usize,
330    /// Lines removed.
331    pub deletions: usize,
332}
333
334/// Stage files in the git index.
335///
336/// If `paths` is empty, stages all modified/untracked files (like `git add -A`).
337pub fn git_add(repo_path: &Path, paths: &[PathBuf]) -> Result<GitAddResult> {
338    let repo = Repository::open(repo_path)
339        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
340
341    let mut index = repo.index().context("Failed to get repository index")?;
342
343    let staged_files = if paths.is_empty() {
344        // Stage all changes (git add -A)
345        index
346            .add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
347            .context("Failed to add all files to index")?;
348
349        // Get list of staged files
350        let statuses = repo.statuses(None)?;
351        statuses
352            .iter()
353            .filter_map(|e| e.path().map(PathBuf::from))
354            .collect()
355    } else {
356        // Stage specific files
357        for path in paths {
358            // Convert to repo-relative path
359            let rel_path = if path.is_absolute() {
360                path.strip_prefix(repo_path).unwrap_or(path)
361            } else {
362                path.as_path()
363            };
364            index
365                .add_path(rel_path)
366                .with_context(|| format!("Failed to add {:?} to index", rel_path))?;
367        }
368        paths.to_vec()
369    };
370
371    index.write().context("Failed to write index")?;
372
373    let count = staged_files.len();
374    Ok(GitAddResult {
375        staged_files,
376        count,
377    })
378}
379
380/// Create a commit with the staged changes.
381pub fn git_commit(repo_path: &Path, message: &str) -> Result<GitCommitResult> {
382    let repo = Repository::open(repo_path)
383        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
384
385    let mut index = repo.index().context("Failed to get repository index")?;
386
387    // Check if there are staged changes
388    let statuses = repo.statuses(None)?;
389    let staged_count = statuses
390        .iter()
391        .filter(|e| {
392            let s = e.status();
393            s.contains(Status::INDEX_NEW)
394                || s.contains(Status::INDEX_MODIFIED)
395                || s.contains(Status::INDEX_DELETED)
396                || s.contains(Status::INDEX_RENAMED)
397        })
398        .count();
399
400    if staged_count == 0 {
401        anyhow::bail!("Nothing to commit - no staged changes");
402    }
403
404    // Write tree from index
405    let tree_id = index.write_tree().context("Failed to write tree")?;
406    let tree = repo.find_tree(tree_id).context("Failed to find tree")?;
407
408    // Get signature (author/committer)
409    let signature = repo
410        .signature()
411        .or_else(|_| {
412            // Fallback signature if not configured
413            Signature::new("Vibe Graph", "vibe-graph@local", &Time::new(chrono_timestamp(), 0))
414        })
415        .context("Failed to get signature")?;
416
417    // Get parent commit (if any)
418    let parent_commit = match repo.head() {
419        Ok(head) => Some(head.peel_to_commit().context("Failed to get parent commit")?),
420        Err(_) => None, // Initial commit
421    };
422
423    let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
424
425    // Create commit
426    let commit_id = repo
427        .commit(
428            Some("HEAD"),
429            &signature,
430            &signature,
431            message,
432            &tree,
433            &parents,
434        )
435        .context("Failed to create commit")?;
436
437    Ok(GitCommitResult {
438        commit_id: commit_id.to_string(),
439        message: message.to_string(),
440        file_count: staged_count,
441    })
442}
443
444/// Unstage files from the index.
445///
446/// If `paths` is empty, unstages all files (like `git reset HEAD`).
447pub fn git_reset(repo_path: &Path, paths: &[PathBuf]) -> Result<GitResetResult> {
448    let repo = Repository::open(repo_path)
449        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
450
451    let head = repo.head().ok();
452    let head_commit = head.as_ref().and_then(|h| h.peel_to_commit().ok());
453
454    let unstaged_files = if paths.is_empty() {
455        // Get list of staged files BEFORE resetting
456        let statuses = repo.statuses(None)?;
457        let staged_files: Vec<PathBuf> = statuses
458            .iter()
459            .filter(|e| {
460                let s = e.status();
461                s.contains(Status::INDEX_NEW)
462                    || s.contains(Status::INDEX_MODIFIED)
463                    || s.contains(Status::INDEX_DELETED)
464            })
465            .filter_map(|e| e.path().map(PathBuf::from))
466            .collect();
467
468        // Reset all staged files by passing them explicitly
469        if !staged_files.is_empty() {
470            if let Some(commit) = &head_commit {
471                let path_refs: Vec<&Path> = staged_files.iter().map(|p| p.as_path()).collect();
472                repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
473                    .context("Failed to reset index")?;
474            } else {
475                // No HEAD commit (initial repo) - reset index to empty
476                let mut index = repo.index()?;
477                for path in &staged_files {
478                    let _ = index.remove_path(path);
479                }
480                index.write()?;
481            }
482        }
483
484        staged_files
485    } else {
486        // Reset specific files
487        if let Some(commit) = &head_commit {
488            let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
489            repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
490                .context("Failed to reset files")?;
491        }
492        paths.to_vec()
493    };
494
495    let count = unstaged_files.len();
496    Ok(GitResetResult {
497        unstaged_files,
498        count,
499    })
500}
501
502/// List all branches in the repository.
503pub fn git_list_branches(repo_path: &Path) -> Result<GitBranchListResult> {
504    let repo = Repository::open(repo_path)
505        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
506
507    let mut branches = Vec::new();
508    let mut current_branch = None;
509
510    // Get current branch name
511    if let Ok(head) = repo.head() {
512        if head.is_branch() {
513            current_branch = head.shorthand().map(String::from);
514        }
515    }
516
517    // Iterate all branches
518    for branch_result in repo.branches(None)? {
519        let (branch, branch_type) = branch_result?;
520        let name = branch.name()?.unwrap_or("").to_string();
521        let is_remote = matches!(branch_type, git2::BranchType::Remote);
522        let is_current = Some(&name) == current_branch.as_ref();
523
524        let commit_id = branch
525            .get()
526            .peel_to_commit()
527            .ok()
528            .map(|c| c.id().to_string());
529
530        branches.push(GitBranch {
531            name,
532            is_current,
533            is_remote,
534            commit_id,
535        });
536    }
537
538    Ok(GitBranchListResult {
539        branches,
540        current: current_branch,
541    })
542}
543
544/// Checkout a branch.
545pub fn git_checkout_branch(repo_path: &Path, branch_name: &str) -> Result<()> {
546    let repo = Repository::open(repo_path)
547        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
548
549    // Find the branch
550    let branch = repo
551        .find_branch(branch_name, git2::BranchType::Local)
552        .with_context(|| format!("Branch '{}' not found", branch_name))?;
553
554    let reference = branch.get();
555    let commit = reference
556        .peel_to_commit()
557        .context("Failed to get commit for branch")?;
558
559    // Checkout the tree
560    let tree = commit.tree().context("Failed to get tree")?;
561    repo.checkout_tree(tree.as_object(), None)
562        .context("Failed to checkout tree")?;
563
564    // Set HEAD to the branch
565    repo.set_head(reference.name().unwrap_or(""))
566        .context("Failed to set HEAD")?;
567
568    Ok(())
569}
570
571/// Get commit log.
572pub fn git_log(repo_path: &Path, limit: usize) -> Result<GitLogResult> {
573    let repo = Repository::open(repo_path)
574        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
575
576    let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
577    revwalk.push_head().context("Failed to push HEAD")?;
578
579    let mut commits = Vec::new();
580
581    for (i, oid_result) in revwalk.enumerate() {
582        if i >= limit {
583            break;
584        }
585
586        let oid = oid_result.context("Failed to get commit OID")?;
587        let commit = repo.find_commit(oid).context("Failed to find commit")?;
588
589        let author = commit.author();
590        commits.push(GitLogEntry {
591            commit_id: oid.to_string(),
592            short_id: oid.to_string()[..7].to_string(),
593            message: commit.message().unwrap_or("").to_string(),
594            author: author.name().unwrap_or("Unknown").to_string(),
595            author_email: author.email().unwrap_or("").to_string(),
596            timestamp: author.when().seconds(),
597        });
598    }
599
600    Ok(GitLogResult { commits })
601}
602
603/// Get diff of staged changes or working directory.
604pub fn git_diff(repo_path: &Path, staged: bool) -> Result<GitDiffResult> {
605    let repo = Repository::open(repo_path)
606        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
607
608    let mut diff_opts = git2::DiffOptions::new();
609    diff_opts.include_untracked(true);
610
611    let diff = if staged {
612        // Diff between HEAD and index (staged changes)
613        let head_tree = repo
614            .head()
615            .ok()
616            .and_then(|h| h.peel_to_tree().ok());
617        repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))
618            .context("Failed to get staged diff")?
619    } else {
620        // Diff between index and working directory (unstaged changes)
621        repo.diff_index_to_workdir(None, Some(&mut diff_opts))
622            .context("Failed to get working directory diff")?
623    };
624
625    let stats = diff.stats().context("Failed to get diff stats")?;
626
627    let mut diff_text = String::new();
628    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
629        let prefix = match line.origin() {
630            '+' => "+",
631            '-' => "-",
632            ' ' => " ",
633            _ => "",
634        };
635        diff_text.push_str(prefix);
636        diff_text.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
637        true
638    })
639    .context("Failed to print diff")?;
640
641    Ok(GitDiffResult {
642        diff: diff_text,
643        files_changed: stats.files_changed(),
644        insertions: stats.insertions(),
645        deletions: stats.deletions(),
646    })
647}
648
649/// Helper to get current unix timestamp.
650fn chrono_timestamp() -> i64 {
651    std::time::SystemTime::now()
652        .duration_since(std::time::UNIX_EPOCH)
653        .map(|d| d.as_secs() as i64)
654        .unwrap_or(0)
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use std::fs;
661    use tempfile::TempDir;
662
663    fn init_test_repo() -> Result<(TempDir, Repository)> {
664        let dir = TempDir::new()?;
665        let repo = Repository::init(dir.path())?;
666        Ok((dir, repo))
667    }
668
669    #[test]
670    fn test_watcher_empty_repo() -> Result<()> {
671        let (dir, _repo) = init_test_repo()?;
672        let mut watcher = GitWatcher::new(dir.path());
673        let snapshot = watcher.force_poll()?;
674        assert!(snapshot.changes.is_empty());
675        Ok(())
676    }
677
678    #[test]
679    fn test_watcher_detects_new_file() -> Result<()> {
680        let (dir, _repo) = init_test_repo()?;
681        fs::write(dir.path().join("new_file.txt"), "hello")?;
682
683        let mut watcher = GitWatcher::new(dir.path());
684        let snapshot = watcher.force_poll()?;
685
686        assert_eq!(snapshot.changes.len(), 1);
687        assert_eq!(snapshot.changes[0].kind, GitChangeKind::Untracked);
688        assert!(!snapshot.changes[0].staged);
689        Ok(())
690    }
691
692    #[test]
693    fn test_watcher_rate_limiting() -> Result<()> {
694        let (dir, _repo) = init_test_repo()?;
695        let config = GitWatcherConfig {
696            min_poll_interval: Duration::from_secs(60), // Long interval
697            ..Default::default()
698        };
699        let mut watcher = GitWatcher::with_config(dir.path(), config);
700
701        // First poll should work
702        assert!(watcher.should_poll());
703        watcher.poll()?;
704
705        // Second poll should be rate-limited
706        assert!(!watcher.should_poll());
707        Ok(())
708    }
709
710    // =========================================================================
711    // Git Command Tests
712    // =========================================================================
713
714    #[test]
715    fn test_git_add_and_commit() -> Result<()> {
716        let (dir, repo) = init_test_repo()?;
717
718        // Configure user for commit
719        let mut config = repo.config()?;
720        config.set_str("user.name", "Test User")?;
721        config.set_str("user.email", "test@example.com")?;
722
723        // Create a file
724        fs::write(dir.path().join("test.txt"), "hello world")?;
725
726        // Stage the file
727        let add_result = git_add(dir.path(), &[])?;
728        assert_eq!(add_result.count, 1);
729
730        // Commit
731        let commit_result = git_commit(dir.path(), "Initial commit")?;
732        assert_eq!(commit_result.message, "Initial commit");
733        assert!(!commit_result.commit_id.is_empty());
734
735        Ok(())
736    }
737
738    #[test]
739    fn test_git_commit_fails_without_staged() -> Result<()> {
740        let (dir, repo) = init_test_repo()?;
741
742        // Configure user for commit
743        let mut config = repo.config()?;
744        config.set_str("user.name", "Test User")?;
745        config.set_str("user.email", "test@example.com")?;
746
747        // Try to commit without staged changes
748        let result = git_commit(dir.path(), "Empty commit");
749        assert!(result.is_err());
750        assert!(result
751            .unwrap_err()
752            .to_string()
753            .contains("Nothing to commit"));
754
755        Ok(())
756    }
757
758    #[test]
759    fn test_git_branches() -> Result<()> {
760        let (dir, repo) = init_test_repo()?;
761
762        // Configure user
763        let mut config = repo.config()?;
764        config.set_str("user.name", "Test User")?;
765        config.set_str("user.email", "test@example.com")?;
766
767        // Create initial commit (needed for branches to exist)
768        fs::write(dir.path().join("test.txt"), "hello")?;
769        git_add(dir.path(), &[])?;
770        git_commit(dir.path(), "Initial")?;
771
772        // List branches
773        let branches = git_list_branches(dir.path())?;
774        assert!(!branches.branches.is_empty());
775
776        // Default branch should be current
777        let current = branches.branches.iter().find(|b| b.is_current);
778        assert!(current.is_some());
779
780        Ok(())
781    }
782
783    #[test]
784    fn test_git_log() -> Result<()> {
785        let (dir, repo) = init_test_repo()?;
786
787        // Configure user
788        let mut config = repo.config()?;
789        config.set_str("user.name", "Test User")?;
790        config.set_str("user.email", "test@example.com")?;
791
792        // Create commits
793        fs::write(dir.path().join("test.txt"), "hello")?;
794        git_add(dir.path(), &[])?;
795        git_commit(dir.path(), "First commit")?;
796
797        fs::write(dir.path().join("test.txt"), "hello world")?;
798        git_add(dir.path(), &[])?;
799        git_commit(dir.path(), "Second commit")?;
800
801        // Get log
802        let log = git_log(dir.path(), 10)?;
803        assert_eq!(log.commits.len(), 2);
804        assert_eq!(log.commits[0].message.trim(), "Second commit");
805        assert_eq!(log.commits[1].message.trim(), "First commit");
806
807        Ok(())
808    }
809
810    #[test]
811    fn test_git_diff() -> Result<()> {
812        let (dir, repo) = init_test_repo()?;
813
814        // Configure user
815        let mut config = repo.config()?;
816        config.set_str("user.name", "Test User")?;
817        config.set_str("user.email", "test@example.com")?;
818
819        // Create initial commit
820        fs::write(dir.path().join("test.txt"), "hello")?;
821        git_add(dir.path(), &[])?;
822        git_commit(dir.path(), "Initial")?;
823
824        // Modify file
825        fs::write(dir.path().join("test.txt"), "hello world")?;
826
827        // Get working directory diff
828        let diff = git_diff(dir.path(), false)?;
829        assert_eq!(diff.files_changed, 1);
830        assert!(diff.insertions > 0 || diff.deletions > 0);
831
832        // Stage and get staged diff
833        git_add(dir.path(), &[])?;
834        let staged_diff = git_diff(dir.path(), true)?;
835        assert_eq!(staged_diff.files_changed, 1);
836
837        Ok(())
838    }
839}