diff --git a/cli/src/commands/git/clone.rs b/cli/src/commands/git/clone.rs index 57f0261089..66c2102617 100644 --- a/cli/src/commands/git/clone.rs +++ b/cli/src/commands/git/clone.rs @@ -1,4 +1,3 @@ -// Copyright 2020-2023 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -222,7 +221,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::fetch_and_get_default_branch( fetch_tx.repo_mut(), &git_repo, remote_name, diff --git a/lib/src/git.rs b/lib/src/git.rs index 6bff6faac8..394b975abf 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -1229,6 +1229,146 @@ pub enum GitFetchError { InternalGitError(#[from] git2::Error), } +fn git_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 +} + +struct FetchedBranches { + branches: Vec, + remote: String, +} + +struct GitFetch<'a> { + mut_repo: &'a mut MutableRepo, + git_repo: &'a git2::Repository, + git_settings: &'a GitSettings, + fetch_options: git2::FetchOptions<'a>, + fetched: Vec, +} + +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, + fetched: vec![], + } + } + + 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( + /* This triggered by non-glob `*`s in addition to INVALID_REFSPEC_CHARS + * because `to_glob()` escapes such `*`s as `[*]`. */ + |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, + )?; + + self.fetched.push(FetchedBranches { + branches: branch_names.to_vec(), + remote: remote_name.to_string(), + }); + + 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) -> 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"); + let import_stats = import_some_refs(self.mut_repo, self.git_settings, |ref_name| { + self.fetched + .iter() + .filter_map(|fetched| { + to_remote_branch(ref_name, &fetched.remote).map(|branch| { + fetched + .branches + .iter() + .any(|pattern| pattern.matches(branch)) + }) + }) + .next() + .unwrap_or(matches!(ref_name, RefName::Tag(_))) + })?; + + self.fetched = vec![]; + + Ok(import_stats) + } +} + /// Describes successful `fetch()` result. #[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct GitFetchStats { @@ -1239,7 +1379,7 @@ pub struct GitFetchStats { } #[tracing::instrument(skip(mut_repo, git_repo, callbacks))] -pub fn fetch( +pub fn fetch_and_get_default_branch( mut_repo: &mut MutableRepo, git_repo: &git2::Repository, remote_name: &str, @@ -1248,81 +1388,14 @@ pub fn fetch( git_settings: &GitSettings, depth: Option, ) -> Result { - // Perform a `git fetch` on the local git repo, updating the remote-tracking - // branches in the git repo. - let mut remote = 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) - } - })?; - let mut fetch_options = git2::FetchOptions::new(); - let mut proxy_options = git2::ProxyOptions::new(); - proxy_options.auto(); - fetch_options.proxy_options(proxy_options); - let callbacks = callbacks.into_git(); - fetch_options.remote_callbacks(callbacks); - if let Some(depth) = depth { - fetch_options.depth(depth.get().try_into().unwrap_or(i32::MAX)); - } - // 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( - /* This triggered by non-glob `*`s in addition to INVALID_REFSPEC_CHARS - * because `to_glob()` escapes such `*`s as `[*]`. */ - |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. - let stats = GitFetchStats::default(); - return Ok(stats); - } - tracing::debug!("remote.download"); - remote.download(&refspecs, Some(&mut 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, - )?; - // TODO: We could make it optional to get the default branch since we only care - // about it on clone. - 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()?; - - // 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"); - let import_stats = import_some_refs(mut_repo, git_settings, |ref_name| { - to_remote_branch(ref_name, remote_name) - .map(|branch| branch_names.iter().any(|pattern| pattern.matches(branch))) - .unwrap_or_else(|| matches!(ref_name, RefName::Tag(_))) - })?; + let mut git_fetch = GitFetch::new( + mut_repo, + git_repo, + git_settings, + git_fetch_options(callbacks, depth), + ); + let default_branch = git_fetch.fetch(branch_names, remote_name)?; + let import_stats = git_fetch.import_refs()?; let stats = GitFetchStats { default_branch, import_stats, @@ -1330,6 +1403,30 @@ pub fn fetch( Ok(stats) } +pub fn fetch( + 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, + git_fetch_options(callbacks, depth), + ); + git_fetch.fetch(branch_names, remote_name)?; + let import_stats = git_fetch.import_refs()?; + let stats = GitFetchStats { + default_branch: None, + import_stats, + }; + Ok(stats) +} + #[derive(Error, Debug, PartialEq)] pub enum GitPushError { #[error("No git remote named '{0}'")] diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index 7f5c5e3335..81b50d37ed 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::fetch_and_get_default_branch( 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::fetch_and_get_default_branch( tx.repo_mut(), &test_data.git_repo, "origin", @@ -2330,6 +2330,68 @@ fn test_fetch_initial_commit() { ); } +#[test] +fn test_fetch_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::fetch_and_get_default_branch( + 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::fetch_and_get_default_branch(). + 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::fetch_and_get_default_branch( tx.repo_mut(), &test_data.git_repo, "origin", @@ -2374,8 +2436,8 @@ fn test_fetch_success() { None, ) .unwrap(); - // The default bookmark is "main" - assert_eq!(stats.default_branch, Some("main".to_string())); + // git::fetch doesn't return a default branch + assert_eq!(stats.default_branch, None); assert!(stats.import_stats.abandoned_commits.is_empty()); let repo = tx.commit("test").unwrap(); // The new commit is visible after we fetch again