Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I07795374f678fa2ec17b4171fa7e32276a6a6964
587 lines
17 KiB
Rust
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");
|
|
}
|
|
}
|