pakker/src/git/mod.rs
NotAShelf b71b2862c9
infra: add clippy allows; fix PathBuf -> Path
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I07795374f678fa2ec17b4171fa7e32276a6a6964
2026-02-19 00:22:44 +03:00

587 lines
17 KiB
Rust

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<P: AsRef<Path>>(path: P) -> bool {
Repository::open(path).is_ok()
}
/// Get the URL of a remote
pub fn get_remote_url<P: AsRef<Path>>(
path: P,
remote_name: &str,
) -> Result<String> {
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<P: AsRef<Path>>(
path: P,
ref_name: Option<&str>,
) -> Result<String> {
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<P: AsRef<Path>>(
path: P,
ref_name: &str,
) -> Result<String> {
get_current_commit_sha(path, Some(ref_name))
}
/// Clone a Git repository
pub fn clone_repository<P: AsRef<Path>>(
url: &str,
target_path: P,
ref_name: &str,
progress_callback: Option<
Box<dyn FnMut(usize, usize, Option<usize>) + 'static>,
>,
) -> Result<Repository> {
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<P: AsRef<Path>>(
path: P,
remote_name: &str,
ref_name: &str,
progress_callback: Option<
Box<dyn FnMut(usize, usize, Option<usize>) + '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<P: AsRef<Path>>(
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<P: AsRef<Path>>(
path: P,
ref_name: &str,
) -> Result<crate::model::fork::RefType> {
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<P: AsRef<Path>>(path: P) -> Result<String> {
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<P: AsRef<Path>>(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<P: AsRef<Path>>(path: P) -> Result<bool> {
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<P: AsRef<Path>>(
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<P: AsRef<Path>>(path: P, ref_name: &str) -> Result<Oid> {
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<usize> {
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<P: AsRef<Path>>(
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<P: AsRef<Path>>(
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");
}
}