Skip to main content

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(
414                "Vibe Graph",
415                "vibe-graph@local",
416                &Time::new(chrono_timestamp(), 0),
417            )
418        })
419        .context("Failed to get signature")?;
420
421    // Get parent commit (if any)
422    let parent_commit = match repo.head() {
423        Ok(head) => Some(
424            head.peel_to_commit()
425                .context("Failed to get parent commit")?,
426        ),
427        Err(_) => None, // Initial commit
428    };
429
430    let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
431
432    // Create commit
433    let commit_id = repo
434        .commit(
435            Some("HEAD"),
436            &signature,
437            &signature,
438            message,
439            &tree,
440            &parents,
441        )
442        .context("Failed to create commit")?;
443
444    Ok(GitCommitResult {
445        commit_id: commit_id.to_string(),
446        message: message.to_string(),
447        file_count: staged_count,
448    })
449}
450
451/// Unstage files from the index.
452///
453/// If `paths` is empty, unstages all files (like `git reset HEAD`).
454pub fn git_reset(repo_path: &Path, paths: &[PathBuf]) -> Result<GitResetResult> {
455    let repo = Repository::open(repo_path)
456        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
457
458    let head = repo.head().ok();
459    let head_commit = head.as_ref().and_then(|h| h.peel_to_commit().ok());
460
461    let unstaged_files = if paths.is_empty() {
462        // Get list of staged files BEFORE resetting
463        let statuses = repo.statuses(None)?;
464        let staged_files: Vec<PathBuf> = statuses
465            .iter()
466            .filter(|e| {
467                let s = e.status();
468                s.contains(Status::INDEX_NEW)
469                    || s.contains(Status::INDEX_MODIFIED)
470                    || s.contains(Status::INDEX_DELETED)
471            })
472            .filter_map(|e| e.path().map(PathBuf::from))
473            .collect();
474
475        // Reset all staged files by passing them explicitly
476        if !staged_files.is_empty() {
477            if let Some(commit) = &head_commit {
478                let path_refs: Vec<&Path> = staged_files.iter().map(|p| p.as_path()).collect();
479                repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
480                    .context("Failed to reset index")?;
481            } else {
482                // No HEAD commit (initial repo) - reset index to empty
483                let mut index = repo.index()?;
484                for path in &staged_files {
485                    let _ = index.remove_path(path);
486                }
487                index.write()?;
488            }
489        }
490
491        staged_files
492    } else {
493        // Reset specific files
494        if let Some(commit) = &head_commit {
495            let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
496            repo.reset_default(Some(commit.as_object()), path_refs.iter().copied())
497                .context("Failed to reset files")?;
498        }
499        paths.to_vec()
500    };
501
502    let count = unstaged_files.len();
503    Ok(GitResetResult {
504        unstaged_files,
505        count,
506    })
507}
508
509/// List all branches in the repository.
510pub fn git_list_branches(repo_path: &Path) -> Result<GitBranchListResult> {
511    let repo = Repository::open(repo_path)
512        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
513
514    let mut branches = Vec::new();
515    let mut current_branch = None;
516
517    // Get current branch name
518    if let Ok(head) = repo.head() {
519        if head.is_branch() {
520            current_branch = head.shorthand().map(String::from);
521        }
522    }
523
524    // Iterate all branches
525    for branch_result in repo.branches(None)? {
526        let (branch, branch_type) = branch_result?;
527        let name = branch.name()?.unwrap_or("").to_string();
528        let is_remote = matches!(branch_type, git2::BranchType::Remote);
529        let is_current = Some(&name) == current_branch.as_ref();
530
531        let commit_id = branch
532            .get()
533            .peel_to_commit()
534            .ok()
535            .map(|c| c.id().to_string());
536
537        branches.push(GitBranch {
538            name,
539            is_current,
540            is_remote,
541            commit_id,
542        });
543    }
544
545    Ok(GitBranchListResult {
546        branches,
547        current: current_branch,
548    })
549}
550
551/// Checkout a branch.
552pub fn git_checkout_branch(repo_path: &Path, branch_name: &str) -> Result<()> {
553    let repo = Repository::open(repo_path)
554        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
555
556    // Find the branch
557    let branch = repo
558        .find_branch(branch_name, git2::BranchType::Local)
559        .with_context(|| format!("Branch '{}' not found", branch_name))?;
560
561    let reference = branch.get();
562    let commit = reference
563        .peel_to_commit()
564        .context("Failed to get commit for branch")?;
565
566    // Checkout the tree
567    let tree = commit.tree().context("Failed to get tree")?;
568    repo.checkout_tree(tree.as_object(), None)
569        .context("Failed to checkout tree")?;
570
571    // Set HEAD to the branch
572    repo.set_head(reference.name().unwrap_or(""))
573        .context("Failed to set HEAD")?;
574
575    Ok(())
576}
577
578/// Get commit log.
579pub fn git_log(repo_path: &Path, limit: usize) -> Result<GitLogResult> {
580    let repo = Repository::open(repo_path)
581        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
582
583    let mut revwalk = repo.revwalk().context("Failed to create revwalk")?;
584    revwalk.push_head().context("Failed to push HEAD")?;
585
586    let mut commits = Vec::new();
587
588    for (i, oid_result) in revwalk.enumerate() {
589        if i >= limit {
590            break;
591        }
592
593        let oid = oid_result.context("Failed to get commit OID")?;
594        let commit = repo.find_commit(oid).context("Failed to find commit")?;
595
596        let author = commit.author();
597        commits.push(GitLogEntry {
598            commit_id: oid.to_string(),
599            short_id: oid.to_string()[..7].to_string(),
600            message: commit.message().unwrap_or("").to_string(),
601            author: author.name().unwrap_or("Unknown").to_string(),
602            author_email: author.email().unwrap_or("").to_string(),
603            timestamp: author.when().seconds(),
604        });
605    }
606
607    Ok(GitLogResult { commits })
608}
609
610/// Get diff of staged changes or working directory.
611pub fn git_diff(repo_path: &Path, staged: bool) -> Result<GitDiffResult> {
612    let repo = Repository::open(repo_path)
613        .with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
614
615    let mut diff_opts = git2::DiffOptions::new();
616    diff_opts.include_untracked(true);
617
618    let diff = if staged {
619        // Diff between HEAD and index (staged changes)
620        let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
621        repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))
622            .context("Failed to get staged diff")?
623    } else {
624        // Diff between index and working directory (unstaged changes)
625        repo.diff_index_to_workdir(None, Some(&mut diff_opts))
626            .context("Failed to get working directory diff")?
627    };
628
629    let stats = diff.stats().context("Failed to get diff stats")?;
630
631    let mut diff_text = String::new();
632    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
633        let prefix = match line.origin() {
634            '+' => "+",
635            '-' => "-",
636            ' ' => " ",
637            _ => "",
638        };
639        diff_text.push_str(prefix);
640        diff_text.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
641        true
642    })
643    .context("Failed to print diff")?;
644
645    Ok(GitDiffResult {
646        diff: diff_text,
647        files_changed: stats.files_changed(),
648        insertions: stats.insertions(),
649        deletions: stats.deletions(),
650    })
651}
652
653/// Helper to get current unix timestamp.
654fn chrono_timestamp() -> i64 {
655    std::time::SystemTime::now()
656        .duration_since(std::time::UNIX_EPOCH)
657        .map(|d| d.as_secs() as i64)
658        .unwrap_or(0)
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use std::fs;
665    use tempfile::TempDir;
666
667    fn init_test_repo() -> Result<(TempDir, Repository)> {
668        let dir = TempDir::new()?;
669        let repo = Repository::init(dir.path())?;
670        Ok((dir, repo))
671    }
672
673    #[test]
674    fn test_watcher_empty_repo() -> Result<()> {
675        let (dir, _repo) = init_test_repo()?;
676        let mut watcher = GitWatcher::new(dir.path());
677        let snapshot = watcher.force_poll()?;
678        assert!(snapshot.changes.is_empty());
679        Ok(())
680    }
681
682    #[test]
683    fn test_watcher_detects_new_file() -> Result<()> {
684        let (dir, _repo) = init_test_repo()?;
685        fs::write(dir.path().join("new_file.txt"), "hello")?;
686
687        let mut watcher = GitWatcher::new(dir.path());
688        let snapshot = watcher.force_poll()?;
689
690        assert_eq!(snapshot.changes.len(), 1);
691        assert_eq!(snapshot.changes[0].kind, GitChangeKind::Untracked);
692        assert!(!snapshot.changes[0].staged);
693        Ok(())
694    }
695
696    #[test]
697    fn test_watcher_rate_limiting() -> Result<()> {
698        let (dir, _repo) = init_test_repo()?;
699        let config = GitWatcherConfig {
700            min_poll_interval: Duration::from_secs(60), // Long interval
701            ..Default::default()
702        };
703        let mut watcher = GitWatcher::with_config(dir.path(), config);
704
705        // First poll should work
706        assert!(watcher.should_poll());
707        watcher.poll()?;
708
709        // Second poll should be rate-limited
710        assert!(!watcher.should_poll());
711        Ok(())
712    }
713
714    // =========================================================================
715    // Git Command Tests
716    // =========================================================================
717
718    #[test]
719    fn test_git_add_and_commit() -> Result<()> {
720        let (dir, repo) = init_test_repo()?;
721
722        // Configure user for commit
723        let mut config = repo.config()?;
724        config.set_str("user.name", "Test User")?;
725        config.set_str("user.email", "test@example.com")?;
726
727        // Create a file
728        fs::write(dir.path().join("test.txt"), "hello world")?;
729
730        // Stage the file
731        let add_result = git_add(dir.path(), &[])?;
732        assert_eq!(add_result.count, 1);
733
734        // Commit
735        let commit_result = git_commit(dir.path(), "Initial commit")?;
736        assert_eq!(commit_result.message, "Initial commit");
737        assert!(!commit_result.commit_id.is_empty());
738
739        Ok(())
740    }
741
742    #[test]
743    fn test_git_commit_fails_without_staged() -> Result<()> {
744        let (dir, repo) = init_test_repo()?;
745
746        // Configure user for commit
747        let mut config = repo.config()?;
748        config.set_str("user.name", "Test User")?;
749        config.set_str("user.email", "test@example.com")?;
750
751        // Try to commit without staged changes
752        let result = git_commit(dir.path(), "Empty commit");
753        assert!(result.is_err());
754        assert!(result
755            .unwrap_err()
756            .to_string()
757            .contains("Nothing to commit"));
758
759        Ok(())
760    }
761
762    #[test]
763    fn test_git_branches() -> Result<()> {
764        let (dir, repo) = init_test_repo()?;
765
766        // Configure user
767        let mut config = repo.config()?;
768        config.set_str("user.name", "Test User")?;
769        config.set_str("user.email", "test@example.com")?;
770
771        // Create initial commit (needed for branches to exist)
772        fs::write(dir.path().join("test.txt"), "hello")?;
773        git_add(dir.path(), &[])?;
774        git_commit(dir.path(), "Initial")?;
775
776        // List branches
777        let branches = git_list_branches(dir.path())?;
778        assert!(!branches.branches.is_empty());
779
780        // Default branch should be current
781        let current = branches.branches.iter().find(|b| b.is_current);
782        assert!(current.is_some());
783
784        Ok(())
785    }
786
787    #[test]
788    fn test_git_log() -> Result<()> {
789        let (dir, repo) = init_test_repo()?;
790
791        // Configure user
792        let mut config = repo.config()?;
793        config.set_str("user.name", "Test User")?;
794        config.set_str("user.email", "test@example.com")?;
795
796        // Create commits
797        fs::write(dir.path().join("test.txt"), "hello")?;
798        git_add(dir.path(), &[])?;
799        git_commit(dir.path(), "First commit")?;
800
801        fs::write(dir.path().join("test.txt"), "hello world")?;
802        git_add(dir.path(), &[])?;
803        git_commit(dir.path(), "Second commit")?;
804
805        // Get log
806        let log = git_log(dir.path(), 10)?;
807        assert_eq!(log.commits.len(), 2);
808        assert_eq!(log.commits[0].message.trim(), "Second commit");
809        assert_eq!(log.commits[1].message.trim(), "First commit");
810
811        Ok(())
812    }
813
814    #[test]
815    fn test_git_diff() -> Result<()> {
816        let (dir, repo) = init_test_repo()?;
817
818        // Configure user
819        let mut config = repo.config()?;
820        config.set_str("user.name", "Test User")?;
821        config.set_str("user.email", "test@example.com")?;
822
823        // Create initial commit
824        fs::write(dir.path().join("test.txt"), "hello")?;
825        git_add(dir.path(), &[])?;
826        git_commit(dir.path(), "Initial")?;
827
828        // Modify file
829        fs::write(dir.path().join("test.txt"), "hello world")?;
830
831        // Get working directory diff
832        let diff = git_diff(dir.path(), false)?;
833        assert_eq!(diff.files_changed, 1);
834        assert!(diff.insertions > 0 || diff.deletions > 0);
835
836        // Stage and get staged diff
837        git_add(dir.path(), &[])?;
838        let staged_diff = git_diff(dir.path(), true)?;
839        assert_eq!(staged_diff.files_changed, 1);
840
841        Ok(())
842    }
843}