diff --git a/cli/src/commands/git/clone.rs b/cli/src/commands/git/clone.rs index 57f026108..19a2badb9 100644 --- a/cli/src/commands/git/clone.rs +++ b/cli/src/commands/git/clone.rs @@ -222,7 +222,7 @@ fn do_git_clone( let mut fetch_tx = workspace_command.start_transaction(); let stats = with_remote_git_callbacks(ui, None, |cb| { - git::fetch( + git::clone( fetch_tx.repo_mut(), &git_repo, remote_name, diff --git a/lib/src/git.rs b/lib/src/git.rs index 3bc7ef297..cd5da0f65 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -1229,6 +1229,126 @@ pub enum GitFetchError { InternalGitError(#[from] git2::Error), } +struct GitFetch<'a> { + mut_repo: &'a mut MutableRepo, + git_repo: &'a git2::Repository, + git_settings: &'a GitSettings, + fetch_options: git2::FetchOptions<'a>, +} + +impl<'a> GitFetch<'a> { + fn new( + mut_repo: &'a mut MutableRepo, + git_repo: &'a git2::Repository, + git_settings: &'a GitSettings, + fetch_options: git2::FetchOptions<'a>, + ) -> Self { + GitFetch { + mut_repo, + git_repo, + git_settings, + fetch_options, + } + } + + fn fetch_options( + callbacks: RemoteCallbacks<'_>, + depth: Option, + ) -> git2::FetchOptions<'_> { + let mut proxy_options = git2::ProxyOptions::new(); + proxy_options.auto(); + + let mut fetch_options = git2::FetchOptions::new(); + fetch_options.proxy_options(proxy_options); + fetch_options.remote_callbacks(callbacks.into_git()); + if let Some(depth) = depth { + fetch_options.depth(depth.get().try_into().unwrap_or(i32::MAX)); + } + + fetch_options + } + + fn fetch( + &mut self, + branch_names: &[StringPattern], + remote_name: &str, + ) -> Result, GitFetchError> { + // Perform a `git fetch` on the local git repo, updating the remote-tracking + // branches in the git repo. + let mut remote = self.git_repo.find_remote(remote_name).map_err(|err| { + if is_remote_not_found_err(&err) { + GitFetchError::NoSuchRemote(remote_name.to_string()) + } else { + GitFetchError::InternalGitError(err) + } + })?; + // At this point, we are only updating Git's remote tracking branches, not the + // local branches. + let refspecs: Vec<_> = branch_names + .iter() + .map(|pattern| { + pattern + .to_glob() + .filter(|glob| !glob.contains(INVALID_REFSPEC_CHARS)) + .map(|glob| format!("+refs/heads/{glob}:refs/remotes/{remote_name}/{glob}")) + }) + .collect::>() + .ok_or(GitFetchError::InvalidBranchPattern)?; + if refspecs.is_empty() { + // Don't fall back to the base refspecs. + return Ok(None); + } + + tracing::debug!("remote.download"); + remote.download(&refspecs, Some(&mut self.fetch_options))?; + tracing::debug!("remote.prune"); + remote.prune(None)?; + tracing::debug!("remote.update_tips"); + remote.update_tips( + None, + git2::RemoteUpdateFlags::empty(), + git2::AutotagOption::Unspecified, + None, + )?; + + let mut default_branch = None; + if let Ok(default_ref_buf) = remote.default_branch() { + if let Some(default_ref) = default_ref_buf.as_str() { + // LocalBranch here is the local branch on the remote, so it's really the remote + // branch + if let Some(RefName::LocalBranch(branch_name)) = parse_git_ref(default_ref) { + tracing::debug!(default_branch = branch_name); + default_branch = Some(branch_name); + } + } + } + tracing::debug!("remote.disconnect"); + remote.disconnect()?; + Ok(default_branch) + } + + pub fn import_refs( + &mut self, + branch_names: &[StringPattern], + remote_names: &[&str], + ) -> Result { + // Import the remote-tracking branches into the jj repo and update jj's + // local branches. We also import local tags since remote tags should have + // been merged by Git. + tracing::debug!("import_refs"); + import_some_refs(self.mut_repo, self.git_settings, |ref_name| { + remote_names + .iter() + .filter_map(|remote_name| { + to_remote_branch(ref_name, remote_name) + .map(|branch| branch_names.iter().any(|pattern| pattern.matches(branch))) + }) + .next() + .unwrap_or(matches!(ref_name, RefName::Tag(_))) + }) + } +} + /// Describes successful `fetch()` result. #[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct GitFetchStats { @@ -1239,6 +1359,30 @@ pub struct GitFetchStats { } #[tracing::instrument(skip(mut_repo, git_repo, callbacks))] +pub fn clone( + mut_repo: &mut MutableRepo, + git_repo: &git2::Repository, + remote_name: &str, + branch_names: &[StringPattern], + callbacks: RemoteCallbacks<'_>, + git_settings: &GitSettings, + depth: Option, +) -> Result { + let mut git_fetch = GitFetch::new( + mut_repo, + git_repo, + git_settings, + GitFetch::fetch_options(callbacks, depth), + ); + let default_branch = git_fetch.fetch(branch_names, remote_name)?; + let import_stats = git_fetch.import_refs(branch_names, &[remote_name])?; + let stats = GitFetchStats { + default_branch, + import_stats, + }; + Ok(stats) +} + pub fn fetch( mut_repo: &mut MutableRepo, git_repo: &git2::Repository, diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index 7f5c5e333..cf8872827 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -2257,12 +2257,12 @@ fn test_init() { } #[test] -fn test_fetch_empty_repo() { +fn test_clone_empty_repo() { let test_data = GitRepoData::create(); let git_settings = GitSettings::default(); let mut tx = test_data.repo.start_transaction(&test_data.settings); - let stats = git::fetch( + let stats = git::clone( tx.repo_mut(), &test_data.git_repo, "origin", @@ -2280,7 +2280,7 @@ fn test_fetch_empty_repo() { } #[test] -fn test_fetch_initial_commit() { +fn test_clone_initial_commit_head_is_not_set() { let test_data = GitRepoData::create(); let git_settings = GitSettings { auto_local_bookmark: true, @@ -2289,7 +2289,7 @@ fn test_fetch_initial_commit() { let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction(&test_data.settings); - let stats = git::fetch( + let stats = git::clone( tx.repo_mut(), &test_data.git_repo, "origin", @@ -2330,6 +2330,68 @@ fn test_fetch_initial_commit() { ); } +#[test] +fn test_clone_initial_commit_head_is_set() { + let test_data = GitRepoData::create(); + let git_settings = GitSettings { + auto_local_bookmark: true, + ..Default::default() + }; + let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); + test_data.origin_repo.set_head("refs/heads/main").unwrap(); + let new_git_commit = empty_git_commit( + &test_data.origin_repo, + "refs/heads/main", + &[&initial_git_commit], + ); + test_data + .origin_repo + .reference("refs/tags/v1.0", new_git_commit.id(), false, "") + .unwrap(); + + let mut tx = test_data.repo.start_transaction(&test_data.settings); + let stats = git::clone( + tx.repo_mut(), + &test_data.git_repo, + "origin", + &[StringPattern::everything()], + git::RemoteCallbacks::default(), + &git_settings, + None, + ) + .unwrap(); + assert_eq!(stats.default_branch, Some("main".to_string())); + assert!(stats.import_stats.abandoned_commits.is_empty()); + let repo = tx.commit("test").unwrap(); + // The new commit visible after git::clone(). + let view = repo.view(); + assert!(view.heads().contains(&jj_id(&new_git_commit))); + let commit_target = RefTarget::normal(jj_id(&new_git_commit)); + let commit_remote_ref = RemoteRef { + target: commit_target.clone(), + state: RemoteRefState::Tracking, + }; + assert_eq!( + *view.git_refs(), + btreemap! { + "refs/remotes/origin/main".to_string() => commit_target.clone(), + "refs/tags/v1.0".to_string() => commit_target.clone(), + + } + ); + assert_eq!( + view.bookmarks().collect::>(), + btreemap! { + "main" => BookmarkTarget { + local_target: &commit_target, + remote_refs: vec![ + ("origin", &commit_remote_ref), + ], + }, + } + ); +} + #[test] fn test_fetch_success() { let mut test_data = GitRepoData::create(); @@ -2340,7 +2402,7 @@ fn test_fetch_success() { let initial_git_commit = empty_git_commit(&test_data.origin_repo, "refs/heads/main", &[]); let mut tx = test_data.repo.start_transaction(&test_data.settings); - git::fetch( + git::clone( tx.repo_mut(), &test_data.git_repo, "origin",