//! Integration tests for the git module. //! //! These tests create real temporary git repos and exercise GitRunner //! and compute_git_state against them. Marked `#[ignore]` by default //! since they require `git` to be installed. use dirigent_projects::git::{compute_git_state, GitRunner}; use std::path::Path; use tokio::process::Command; /// Helper: initialize a git repo in the given directory with an initial commit. async fn init_repo(dir: &Path) { run(dir, &["git", "init"]).await; run(dir, &["git", "config", "user.email", "test@test.com"]).await; run(dir, &["git", "config", "user.name", "Test"]).await; // Create an initial commit so HEAD exists let file = dir.join("README.md"); tokio::fs::write(&file, "# Test\n").await.unwrap(); run(dir, &["git", "add", "."]).await; run(dir, &["git", "commit", "-m", "Initial commit"]).await; } async fn run(dir: &Path, args: &[&str]) { let status = Command::new(args[0]) .args(&args[1..]) .current_dir(dir) .output() .await .unwrap_or_else(|e| panic!("Failed to run {:?}: {e}", args)); assert!( status.status.success(), "{:?} failed: {}", args, String::from_utf8_lossy(&status.stderr) ); } #[tokio::test] #[ignore = "requires git"] async fn test_current_branch() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; let runner = GitRunner::new(dir.path()); let branch = runner.current_branch().await.unwrap(); // Default branch may be "main" or "master" depending on git config assert!( branch == "main" || branch == "master", "unexpected branch: {branch}" ); } #[tokio::test] #[ignore = "requires git"] async fn test_status_clean() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; let runner = GitRunner::new(dir.path()); let status = runner.status().await.unwrap(); assert!(!status.is_dirty); assert_eq!(status.ahead, 0); assert_eq!(status.behind, 0); } #[tokio::test] #[ignore = "requires git"] async fn test_status_dirty() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; // Create an untracked file tokio::fs::write(dir.path().join("dirty.txt"), "dirty") .await .unwrap(); let runner = GitRunner::new(dir.path()); let status = runner.status().await.unwrap(); assert!(status.is_dirty); } #[tokio::test] #[ignore = "requires git"] async fn test_remotes_empty() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; let runner = GitRunner::new(dir.path()); let remotes = runner.remotes().await.unwrap(); assert!(remotes.is_empty()); } #[tokio::test] #[ignore = "requires git"] async fn test_worktree_list_single() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; let runner = GitRunner::new(dir.path()); let worktrees = runner.worktree_list().await.unwrap(); // A non-bare repo always has at least the main worktree assert_eq!(worktrees.len(), 1); assert!(worktrees[0].branch.is_some()); } #[tokio::test] #[ignore = "requires git"] async fn test_worktree_add_and_list() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; let wt_path = dir.path().join("wt-feature"); // Create a branch first run(dir.path(), &["git", "branch", "feature"]).await; let runner = GitRunner::new(dir.path()); runner.worktree_add(&wt_path, "feature").await.unwrap(); let worktrees = runner.worktree_list().await.unwrap(); assert_eq!(worktrees.len(), 2); // Find the feature worktree by branch name (paths may differ due to symlink canonicalization) let feature_wt = worktrees .iter() .find(|w| w.branch.as_deref() == Some("feature")) .expect("should find worktree with branch 'feature'"); assert!(!feature_wt.is_detached); } #[tokio::test] #[ignore = "requires git"] async fn test_compute_git_state() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; // Make it dirty tokio::fs::write(dir.path().join("new.txt"), "content") .await .unwrap(); let runner = GitRunner::new(dir.path()); let state = compute_git_state(&runner).await; assert!(!state.branch.is_empty()); assert!(state.is_dirty); assert!( state.unexpected.is_empty(), "unexpected warnings: {:?}", state.unexpected ); // Should have at least the main worktree assert!(!state.worktrees.is_empty()); } #[tokio::test] #[ignore = "requires git"] async fn test_graceful_degradation_not_a_repo() { let dir = tempfile::tempdir().unwrap(); // Don't init — not a git repo let runner = GitRunner::new(dir.path()); let state = compute_git_state(&runner).await; // Should have warnings, not panic assert!(!state.unexpected.is_empty()); } #[tokio::test] #[ignore = "requires git"] async fn test_commit_returns_hash() { let dir = tempfile::tempdir().unwrap(); init_repo(dir.path()).await; let file = dir.path().join("commit_test.txt"); tokio::fs::write(&file, "data").await.unwrap(); run(dir.path(), &["git", "add", "."]).await; let runner = GitRunner::new(dir.path()); let hash = runner.commit("test commit").await.unwrap(); // SHA-1 hash is 40 hex chars assert_eq!(hash.len(), 40, "unexpected hash: {hash}"); assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); }