use std::path::Path; use git2::{ Cred, FetchOptions, Oid, RemoteCallbacks, Repository, ResetType, build::RepoBuilder, }; use crate::error::{PakkerError, Result}; /// Check if a directory is a Git repository pub fn is_git_repository>(path: P) -> bool { Repository::open(path).is_ok() } /// Get the URL of a remote pub fn get_remote_url>( path: P, remote_name: &str, ) -> Result { let repo = Repository::open(path)?; let remote = repo.find_remote(remote_name).map_err(|e| { PakkerError::GitError(format!("Remote '{remote_name}' not found: {e}")) })?; remote .url() .ok_or_else(|| { PakkerError::GitError("Remote URL is not valid UTF-8".to_string()) }) .map(std::string::ToString::to_string) } pub fn get_current_commit_sha>( path: P, ref_name: Option<&str>, ) -> Result { let repo = Repository::open(path)?; let commit = if let Some(ref_name) = ref_name { let obj = repo.revparse_single(ref_name)?; obj.peel_to_commit()? } else { let head = repo.head()?; head.peel_to_commit()? }; Ok(commit.id().to_string()) } /// Get the commit SHA for a specific ref (alias for compatibility) pub fn get_commit_sha>( path: P, ref_name: &str, ) -> Result { get_current_commit_sha(path, Some(ref_name)) } /// Clone a Git repository pub fn clone_repository>( url: &str, target_path: P, ref_name: &str, progress_callback: Option< Box) + 'static>, >, ) -> Result { let target_path = target_path.as_ref(); // Check if target directory exists and is not empty if target_path.exists() { let is_empty = target_path.read_dir()?.next().is_none(); if !is_empty { return Err(PakkerError::GitError(format!( "Target directory is not empty: {}", target_path.display() ))); } } let mut callbacks = RemoteCallbacks::new(); // Setup SSH key authentication callbacks.credentials(|_url, username_from_url, _allowed_types| { let username = username_from_url.unwrap_or("git"); Cred::ssh_key_from_agent(username) }); // Setup progress callback if provided if let Some(mut progress_fn) = progress_callback { callbacks.transfer_progress(move |stats| { progress_fn( stats.received_objects(), stats.total_objects(), Some(stats.received_bytes()), ); true }); } let mut fetch_options = FetchOptions::new(); fetch_options.remote_callbacks(callbacks); let mut builder = RepoBuilder::new(); builder.fetch_options(fetch_options); // Perform the clone. Avoid forcing a branch at clone time because some // local repositories (or bare repos) may not expose the exact remote // tracking refs that libgit2 expects. We'll attempt to set the desired // ref after cloning when possible. let repo = builder.clone(url, target_path).map_err(|e| { PakkerError::GitError(format!("Failed to clone repository '{url}': {e}")) })?; // If a branch/ref name was requested, try to make HEAD point to it. // Prefer local branch refs (refs/heads/*), then tags, then raw rev-parse. let branch_ref = format!("refs/heads/{ref_name}"); if repo.find_reference(&branch_ref).is_ok() { repo.set_head(&branch_ref).map_err(|e| { PakkerError::GitError(format!( "Cloned repository but failed to set HEAD to {branch_ref}: {e}" )) })?; } else if let Ok(obj) = repo.revparse_single(ref_name) { // Create a detached HEAD pointing to the commit/tag let commit = obj.peel_to_commit().map_err(|e| { PakkerError::GitError(format!( "Resolved ref '{ref_name}' but could not peel to commit: {e}" )) })?; repo.set_head_detached(commit.id()).map_err(|e| { PakkerError::GitError(format!( "Cloned repository but failed to set detached HEAD to {ref_name}: {e}" )) })?; } Ok(repo) } /// Fetch updates from a remote pub fn fetch_updates>( path: P, remote_name: &str, ref_name: &str, progress_callback: Option< Box) + 'static>, >, ) -> Result<()> { let repo = Repository::open(path)?; let mut remote = repo.find_remote(remote_name).map_err(|e| { PakkerError::GitError(format!("Remote '{remote_name}' not found: {e}")) })?; let mut callbacks = RemoteCallbacks::new(); // Setup SSH key authentication callbacks.credentials(|_url, username_from_url, _allowed_types| { let username = username_from_url.unwrap_or("git"); Cred::ssh_key_from_agent(username) }); // Setup progress callback if provided if let Some(mut progress_fn) = progress_callback { callbacks.transfer_progress(move |stats| { progress_fn( stats.received_objects(), stats.total_objects(), Some(stats.received_bytes()), ); true }); } let mut fetch_options = FetchOptions::new(); fetch_options.remote_callbacks(callbacks); remote .fetch(&[ref_name], Some(&mut fetch_options), None) .map_err(|e| { PakkerError::GitError(format!("Failed to fetch updates: {e}")) })?; Ok(()) } /// Hard reset to a specific ref (like git reset --hard) pub fn reset_to_ref>( path: P, remote_name: &str, ref_name: &str, ) -> Result<()> { let repo = Repository::open(path)?; // Construct the full ref path (e.g., "origin/main") let full_ref = format!("{remote_name}/{ref_name}"); let obj = repo.revparse_single(&full_ref).map_err(|e| { PakkerError::GitError(format!("Failed to find ref '{full_ref}': {e}")) })?; let commit = obj.peel_to_commit().map_err(|e| { PakkerError::GitError(format!("Failed to resolve ref to commit: {e}")) })?; repo .reset(commit.as_object(), ResetType::Hard, None) .map_err(|e| { PakkerError::GitError(format!("Failed to reset to ref: {e}")) })?; Ok(()) } /// Determine the ref type (branch, tag, or commit) pub fn resolve_ref_type>( path: P, ref_name: &str, ) -> Result { let repo = Repository::open(path)?; // Check if it's a branch if repo.find_branch(ref_name, git2::BranchType::Local).is_ok() || repo.find_branch(ref_name, git2::BranchType::Remote).is_ok() { return Ok(crate::model::fork::RefType::Branch); } // Check if it's a tag let tag_ref = format!("refs/tags/{ref_name}"); if repo.find_reference(&tag_ref).is_ok() { return Ok(crate::model::fork::RefType::Tag); } // Try to resolve as commit SHA if repo.revparse_single(ref_name).is_ok() { return Ok(crate::model::fork::RefType::Commit); } Err(PakkerError::GitError(format!( "Could not resolve ref '{ref_name}' as branch, tag, or commit" ))) } /// Get the primary remote URL for a repository at path. Prefer 'origin', /// otherwise first remote with a URL. pub fn get_primary_remote_url>(path: P) -> Result { let repo = Repository::open(path)?; if let Ok(remote) = repo.find_remote("origin") && let Some(url) = remote.url() { return Ok(url.to_string()); } // Fallback: first remote with a URL if let Ok(remotes) = repo.remotes() { for name in remotes.iter().flatten() { if let Ok(remote) = repo.find_remote(name) && let Some(url) = remote.url() { return Ok(url.to_string()); } } } Err(PakkerError::GitError( "No remote with a valid URL found on repository".to_string(), )) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VcsType { Git, Jujutsu, None, } /// Detect the VCS type for a given path pub fn detect_vcs_type>(path: P) -> VcsType { let path = path.as_ref(); // Check for jujutsu first (higher priority) if let Ok(output) = std::process::Command::new("jj") .args(["root"]) .current_dir(path) .output() && output.status.success() { return VcsType::Jujutsu; } // Check for git if let Ok(output) = std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) .current_dir(path) .output() && output.status.success() { return VcsType::Git; } VcsType::None } /// Check whether the repository has uncommitted changes (working tree or index) pub fn repo_has_uncommitted_changes>(path: P) -> Result { let vcs_type = detect_vcs_type(&path); match vcs_type { VcsType::Git => { let repo = Repository::open(path)?; let statuses = repo.statuses(None)?; for entry in statuses.iter() { let s = entry.status(); // Consider any change in index or working tree as uncommitted if !(s.is_empty()) { return Ok(true); } } Ok(false) }, VcsType::Jujutsu => { // Use jj status to check for changes - look for "The working copy has no // changes" let output = std::process::Command::new("jj") .args(["status"]) .current_dir(path) .output() .map_err(|e| { PakkerError::GitError(format!("Failed to run jj status: {e}")) })?; let output_str = String::from_utf8_lossy(&output.stdout); // Check if the output indicates no changes Ok(!output_str.contains("The working copy has no changes")) }, VcsType::None => Ok(false), } } /// Attempt a lightweight fetch of a single ref from the named remote into the /// repository at path pub fn fetch_remote_light>( path: P, remote_name: &str, ref_name: &str, ) -> Result<()> { let repo = Repository::open(path)?; let mut remote = repo.find_remote(remote_name).map_err(|e| { PakkerError::GitError(format!("Remote '{remote_name}' not found: {e}")) })?; let mut callbacks = RemoteCallbacks::new(); callbacks.credentials(|_url, username_from_url, _allowed_types| { let username = username_from_url.unwrap_or("git"); Cred::ssh_key_from_agent(username) }); let mut fetch_options = FetchOptions::new(); fetch_options.remote_callbacks(callbacks); // Build a refspec that attempts to fetch the branch into the remote-tracking // namespace let fetch_refspec = if ref_name.starts_with("refs/") { ref_name.to_string() } else { format!("refs/heads/{ref_name}:refs/remotes/{remote_name}/{ref_name}") }; remote .fetch(&[&fetch_refspec], Some(&mut fetch_options), None) .map_err(|e| { PakkerError::GitError(format!("Failed lightweight fetch: {e}")) })?; Ok(()) } /// Resolve a ref name to an Oid (commit) pub fn get_ref_oid>(path: P, ref_name: &str) -> Result { let repo = Repository::open(path)?; let obj = repo.revparse_single(ref_name).map_err(|e| { PakkerError::GitError(format!("Failed to resolve ref '{ref_name}': {e}")) })?; let commit = obj.peel_to_commit().map_err(|e| { PakkerError::GitError(format!( "Failed to peel ref '{ref_name}' to commit: {e}" )) })?; Ok(commit.id()) } /// Count commits reachable from `oid` in `repo` fn count_commits(repo: &Repository, oid: Oid) -> Result { let mut revwalk = repo.revwalk().map_err(|e| { PakkerError::GitError(format!( "Failed to create revwalk for counting commits: {e}" )) })?; revwalk.push(oid).map_err(|e| { PakkerError::GitError(format!( "Failed to start revwalk from oid {oid}: {e}" )) })?; let mut count = 0usize; for _ in revwalk { count += 1; } Ok(count) } /// Compute how many commits `local_ref` is ahead/behind `remote_ref` pub fn ahead_behind>( path: P, local_ref: &str, remote_ref: &str, ) -> Result<(usize, usize)> { let repo = Repository::open(&path)?; // Try to resolve local OID let local_oid = match get_ref_oid(&path, local_ref) { Ok(oid) => oid, Err(e) => { return Err(PakkerError::GitError(format!( "Local ref not found '{local_ref}': {e}" ))); }, }; // Try to resolve remote OID. If remote ref is missing, consider remote empty // and count all commits in local as "ahead". if let Ok(remote_oid) = get_ref_oid(&path, remote_ref) { let (ahead, behind) = repo .graph_ahead_behind(local_oid, remote_oid) .map_err(|e| { PakkerError::GitError(format!("Failed to compute ahead/behind: {e}")) })?; Ok((ahead, behind)) } else { // Remote ref missing — count commits reachable from local let ahead_count = count_commits(&repo, local_oid)?; Ok((ahead_count, 0)) } } /// Set the URL for a remote in the repository pub fn set_remote_url>( path: P, remote_name: &str, url: &str, ) -> Result<()> { let repo = Repository::open(path)?; repo.remote_set_url(remote_name, url).map_err(|e| { PakkerError::GitError(format!("Failed to set remote URL: {e}")) })?; Ok(()) } #[cfg(test)] mod tests { use std::{fs::File, io::Write}; use git2::{Repository, Signature}; use tempfile::tempdir; use super::*; fn init_bare_repo(path: &std::path::Path) -> Repository { Repository::init_bare(path).expect("init bare") } fn init_repo_with_commit( path: &std::path::Path, file_name: &str, content: &str, branch: &str, ) -> Repository { let repo = Repository::init(path).expect("init repo"); let sig = Signature::now("Test", "test@example.com").unwrap(); let mut index = repo.index().unwrap(); let file_path = path.join(file_name); let mut f = File::create(&file_path).unwrap(); writeln!(f, "{}", content).unwrap(); drop(f); index.add_path(std::path::Path::new(file_name)).unwrap(); let tree_id = index.write_tree().unwrap(); // limit the scope of tree to avoid borrow while moving repo { let tree = repo.find_tree(tree_id).unwrap(); let _commit_id = repo .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]) .unwrap(); } // Create branch pointing at HEAD and set HEAD let head_oid = repo.refname_to_id("HEAD").unwrap(); repo .branch(branch, &repo.find_commit(head_oid).unwrap(), true) .unwrap(); repo.set_head(&format!("refs/heads/{}", branch)).unwrap(); repo } #[test] fn test_is_git_repository_and_remote_url() { let tmp = tempdir().unwrap(); let repo_path = tmp.path().join("repo"); let _repo = init_repo_with_commit(&repo_path, "a.txt", "hello", "master"); assert!(is_git_repository(&repo_path)); } #[test] fn test_fetch_remote_light_and_ahead_behind() { let tmp = tempdir().unwrap(); let bare_path = tmp.path().join("bare.git"); let _bare = init_bare_repo(&bare_path); let work_path = tmp.path().join("work"); let repo = init_repo_with_commit(&work_path, "a.txt", "hello", "master"); // Add bare remote and push repo.remote("origin", bare_path.to_str().unwrap()).unwrap(); let mut remote = repo.find_remote("origin").unwrap(); remote.connect(git2::Direction::Push).unwrap(); remote .push(&["refs/heads/master:refs/heads/master"], None) .unwrap(); // Ensure bare HEAD points to master let bare_repo = Repository::open(&bare_path).unwrap(); bare_repo.set_head("refs/heads/master").unwrap(); // Now test fetch_remote_light against the work repo (fetch from origin into // work should succeed) assert!(fetch_remote_light(&work_path, "origin", "master").is_ok()); // Test ahead_behind with remote tracking ref let (ahead, behind) = ahead_behind( &work_path, "refs/heads/master", "refs/remotes/origin/master", ) .unwrap(); assert_eq!(ahead, 0); assert_eq!(behind, 0); } #[test] fn test_clone_repository_and_origin_rewrite_integration() { let tmp = tempdir().unwrap(); let bare_path = tmp.path().join("upstream.git"); let _bare = init_bare_repo(&bare_path); let work_path = tmp.path().join("workrepo"); let repo = init_repo_with_commit(&work_path, "b.txt", "hello2", "master"); // Add remote upstream and push repo.remote("origin", bare_path.to_str().unwrap()).unwrap(); let mut remote = repo.find_remote("origin").unwrap(); remote.connect(git2::Direction::Push).unwrap(); remote .push(&["refs/heads/master:refs/heads/master"], None) .unwrap(); let bare_repo = Repository::open(&bare_path).unwrap(); bare_repo.set_head("refs/heads/master").unwrap(); // Now clone from the local path into a new dir let clone_target = tmp.path().join("clone_target"); let _cloned = clone_repository( bare_path.to_str().unwrap(), &clone_target, "master", None, ) .expect("clone"); // After cloning from a local path, simulate rewriting origin to the // upstream network URL set_remote_url(&clone_target, "origin", "https://example.com/upstream.git") .unwrap(); let url = get_remote_url(&clone_target, "origin").unwrap(); assert_eq!(url, "https://example.com/upstream.git"); } }