use anyhow::{Context, Error};
use crate::git::repository::core::GitRepo;
impl GitRepo {
pub fn fetch(&self, remote_name: &str, branch_name: Option<&str>) -> Result<String, Error> {
let mut remote = self
.repo()
.find_remote(remote_name)
.context(format!("Remote '{remote_name}' not found"))?;
let refspecs = match branch_name {
Some(branch) => {
vec![format!(
"refs/heads/{branch}:refs/remotes/{remote_name}/{branch}"
)]
}
None => {
let refspecs = remote
.fetch_refspecs()
.context("Failed to get remote refspecs")?;
let mut result = Vec::new();
for i in 0..refspecs.len() {
if let Some(refspec) = refspecs.get(i) {
result.push(refspec.to_string());
}
}
result
}
};
let refspecs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
remote
.fetch(&refspecs, None, None)
.context("Failed to fetch from remote")?;
let stats = remote.stats();
let received_objects = stats.received_objects();
let total_objects = stats.total_objects();
if received_objects > 0 {
Ok(format!(
"Fetched {received_objects}/{total_objects} objects from {remote_name}"
))
} else {
Ok("Already up-to-date".to_string())
}
}
pub fn pull(&self, remote_name: &str, branch_name: Option<&str>) -> Result<String, Error> {
let target_branch = match branch_name {
Some(branch) => branch.to_string(),
None => self
.get_current_branch()
.context("Failed to get current branch")?,
};
self.fetch(remote_name, Some(&target_branch))
.context("Failed to fetch from remote")?;
let remote_branch = format!("{remote_name}/{target_branch}");
let remote_ref = format!("refs/remotes/{remote_branch}");
let remote_obj = self.repo().revparse_single(&remote_ref).context(format!(
"Remote branch '{remote_branch}' not found after fetch"
))?;
let remote_commit = remote_obj
.peel_to_commit()
.context("Failed to get remote commit")?;
let head_ref = self.repo().head().context("Failed to get HEAD")?;
let head_commit = head_ref
.peel_to_commit()
.context("Failed to get current commit")?;
if head_commit.id() == remote_commit.id() {
return Ok("Already up-to-date".to_string());
}
let merge_base = self
.repo()
.merge_base(head_commit.id(), remote_commit.id())
.context("Failed to find merge base")?;
if merge_base == head_commit.id() {
let current_branch_name = self
.get_current_branch()
.context("Failed to get current branch")?;
let branch_ref_name = format!("refs/heads/{current_branch_name}");
self.repo()
.reference(
&branch_ref_name,
remote_commit.id(),
true,
"Fast-forward pull",
)
.context("Failed to update branch reference")?;
if !self.is_bare() {
let remote_tree = remote_commit.tree().context("Failed to get remote tree")?;
let mut checkout_opts = git2::build::CheckoutBuilder::new();
checkout_opts.force();
self.repo()
.checkout_tree(remote_tree.as_object(), Some(&mut checkout_opts))
.context("Failed to checkout remote tree")?;
}
Ok(format!(
"Fast-forward pull: {remote_commit_id}",
remote_commit_id = remote_commit.id()
))
} else if merge_base == remote_commit.id() {
Ok("Already up-to-date".to_string())
} else {
let signature = self
.create_signature()
.context("Failed to create signature")?;
let head_tree = head_commit.tree().context("Failed to get HEAD tree")?;
let mut index = self.repo().index().context("Failed to get index")?;
index
.read_tree(&head_tree)
.context("Failed to read head tree")?;
let mut merge_options = git2::MergeOptions::new();
let mut checkout_opts = git2::build::CheckoutBuilder::new();
checkout_opts.conflict_style_merge(true);
let annotated_commit = self
.repo()
.find_annotated_commit(remote_commit.id())
.context("Failed to create annotated commit")?;
let (analysis, _) = self
.repo()
.merge_analysis(&[&annotated_commit])
.context("Failed to analyze merge")?;
if analysis.is_up_to_date() {
Ok("Already up-to-date".to_string())
} else if analysis.is_fast_forward() {
self.repo()
.reference(
&format!("refs/heads/{target_branch}"),
remote_commit.id(),
true,
"Fast-forward pull",
)
.context("Failed to fast-forward pull")?;
Ok(format!(
"Fast-forward pull: {remote_commit_id}",
remote_commit_id = remote_commit.id()
))
} else if analysis.is_normal() {
self.repo()
.merge(
&[&annotated_commit],
Some(&mut merge_options),
Some(&mut checkout_opts),
)
.context("Failed to perform merge")?;
let mut index = self
.repo()
.index()
.context("Failed to get index after merge")?;
if index.has_conflicts() {
return Err(anyhow::anyhow!(
"Merge conflicts detected during pull. Please resolve conflicts and commit manually."
));
}
let tree_id = index.write_tree().context("Failed to write merge tree")?;
let tree = self
.repo()
.find_tree(tree_id)
.context("Failed to find merge tree")?;
let commit_message = format!("Merge branch '{remote_branch}' into {target_branch}");
let merge_commit_id = self
.repo()
.commit(
Some("HEAD"),
&signature,
&signature,
&commit_message,
&tree,
&[&head_commit, &remote_commit],
)
.context("Failed to create merge commit")?;
self.repo()
.cleanup_state()
.context("Failed to cleanup merge state")?;
Ok(format!("Pull merge commit created: {merge_commit_id}"))
} else {
Err(anyhow::anyhow!(
"Unsupported merge analysis result during pull"
))
}
}
}
}
#[cfg(test)]
mod tests {
use crate::test_utils::{
create_test_bare_repo, create_test_repo, RepoAssertions, RepoTestOperations,
};
#[test]
fn fetch_works() {
let (_remote_dir, remote_repo) = create_test_bare_repo();
let (_local_dir, local_repo) = create_test_repo();
local_repo
.add_file_and_commit("README.md", "initial", "Initial commit")
.unwrap();
local_repo.add_local_remote("origin", &remote_repo).unwrap();
local_repo.push("origin", "master").unwrap();
local_repo.create_and_checkout_branch("feature").unwrap();
local_repo
.add_file_and_commit("feature.txt", "feature content", "Add feature")
.unwrap();
local_repo.push("origin", "feature").unwrap();
local_repo.checkout_branch("master").unwrap();
let result = local_repo.fetch("origin", Some("feature")).unwrap();
assert!(result.contains("Fetched") || result.contains("up-to-date"));
let result = local_repo.fetch("origin", None).unwrap();
assert!(result.contains("Fetched") || result.contains("up-to-date"));
let result = local_repo.fetch("nonexistent", None);
assert!(result.is_err());
}
#[test]
fn pull_works() {
let (_remote_dir, remote_repo) = create_test_bare_repo();
let (local_dir, local_repo) = create_test_repo();
local_repo
.add_file_and_commit("README.md", "initial", "Initial commit")
.unwrap();
local_repo.add_local_remote("origin", &remote_repo).unwrap();
local_repo.push("origin", "master").unwrap();
local_repo
.add_file_and_commit("new_file.txt", "new content", "Add new file")
.unwrap();
local_repo.push("origin", "master").unwrap();
let commits = local_repo.list_commits().unwrap();
assert!(commits.len() >= 2);
let previous_commit_hash = &commits[1].hash;
std::process::Command::new("git")
.args(["reset", "--hard", previous_commit_hash])
.current_dir(local_dir.path())
.output()
.unwrap();
let result = local_repo.pull("origin", Some("master")).unwrap();
assert!(result.contains("Fast-forward") || result.contains("up-to-date"));
local_repo.assert_file_exists("new_file.txt");
let result = local_repo.pull("origin", Some("master")).unwrap();
assert_eq!(result, "Already up-to-date");
let result = local_repo.pull("nonexistent", None);
assert!(result.is_err());
}
}