// 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.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License. use std::fs;use std::io;use std::io::Write;use std::num::NonZeroU32;use std::path::Path;use std::path::PathBuf; use jj_lib::git;use jj_lib::git::GitFetchError;use jj_lib::git::GitFetchStats;use jj_lib::repo::Repo;use jj_lib::str_util::StringPattern;use jj_lib::workspace::Workspace; use crate::cli_util::CommandHelper;use crate::cli_util::WorkspaceCommandHelper;use crate::command_error::cli_error;use crate::command_error::user_error;use crate::command_error::user_error_with_message;use crate::command_error::CommandError;use crate::commands::git::maybe_add_gitignore;use crate::config::write_config_value_to_file;use crate::config::ConfigNamePathBuf;use crate::git_util::get_git_repo;use crate::git_util::map_git_error;use crate::git_util::print_git_import_stats;use crate::git_util::with_remote_git_callbacks;use crate::ui::Ui; /// Create a new repo backed by a clone of a Git repo////// The Git repo will be a bare git repo stored inside the `.jj/` directory.#[derive(clap::Args, Clone, Debug)]pub struct GitCloneArgs { /// URL or path of the Git repo to clone #[arg(value_hint = clap::ValueHint::DirPath)] source: String, /// Specifies the target directory for the Jujutsu repository clone. /// If not provided, defaults to a directory named after the last component /// of the source URL. The full directory path will be created if it /// doesn't exist. #[arg(value_hint = clap::ValueHint::DirPath)] destination: Option<String>, /// Name of the newly created remote #[arg(long = "remote", default_value = "origin")] remote_name: String, /// Whether or not to colocate the Jujutsu repo with the git repo #[arg(long)] colocate: bool, /// Create a shallow clone of the given depth #[arg(long)] depth: Option<NonZeroU32>,} fn absolute_git_source(cwd: &Path, source: &str) -> String { // Git appears to turn URL-like source to absolute path if local git directory // exits, and fails because '$PWD/https' is unsupported protocol. Since it would // be tedious to copy the exact git (or libgit2) behavior, we simply assume a // source containing ':' is a URL, SSH remote, or absolute path with Windows // drive letter. if !source.contains(':') && Path::new(source).exists() { // It's less likely that cwd isn't utf-8, so just fall back to original source. cwd.join(source) .into_os_string() .into_string() .unwrap_or_else(|_| source.to_owned()) } else { source.to_owned() }} fn clone_destination_for_source(source: &str) -> Option<&str> { let destination = source.strip_suffix(".git").unwrap_or(source); let destination = destination.strip_suffix('/').unwrap_or(destination); destination .rsplit_once(&['/', '\\', ':'][..]) .map(|(_, name)| name)} fn is_empty_dir(path: &Path) -> bool { if let Ok(mut entries) = path.read_dir() { entries.next().is_none() } else { false }} pub fn cmd_git_clone( ui: &mut Ui, command: &CommandHelper, args: &GitCloneArgs,) -> Result<(), CommandError> { let remote_name = &args.remote_name; if command.global_args().at_operation.is_some() { return Err(cli_error("--at-op is not respected")); } let source = absolute_git_source(command.cwd(), &args.source); let wc_path_str = args .destination .as_deref() .or_else(|| clone_destination_for_source(&source)) .ok_or_else(|| user_error("No destination specified and wasn't able to guess it"))?; let wc_path = command.cwd().join(wc_path_str); let wc_path_existed = wc_path.exists(); if wc_path_existed && !is_empty_dir(&wc_path) { return Err(user_error( "Destination path exists and is not an empty directory", )); } // will create a tree dir in case if was deleted after last check fs::create_dir_all(&wc_path) .map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?; // Canonicalize because fs::remove_dir_all() doesn't seem to like e.g. // `/some/path/.` let canonical_wc_path: PathBuf = wc_path .canonicalize() .map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?; let clone_result = do_git_clone( ui, command, args.colocate, args.depth, remote_name, &source, &canonical_wc_path, ); if clone_result.is_err() { let clean_up_dirs = || -> io::Result<()> { fs::remove_dir_all(canonical_wc_path.join(".jj"))?; if args.colocate { fs::remove_dir_all(canonical_wc_path.join(".git"))?; } if !wc_path_existed { fs::remove_dir(&canonical_wc_path)?; } Ok(()) }; if let Err(err) = clean_up_dirs() { writeln!( ui.warning_default(), "Failed to clean up {}: {}", canonical_wc_path.display(), err ) .ok(); } } let (mut workspace_command, stats) = clone_result?; if let Some(default_branch) = &stats.default_branch { // Set repository level `trunk()` alias to the default remote branch. let config_path = workspace_command.repo_path().join("config.toml"); write_config_value_to_file( &ConfigNamePathBuf::from_iter(["revset-aliases", "trunk()"]), format!("{default_branch}@{remote_name}").into(), &config_path, )?; writeln!( ui.status(), "Setting the revset alias \"trunk()\" to \"{default_branch}@{remote_name}\"" )?; let default_branch_remote_ref = workspace_command .repo() .view() .get_remote_bookmark(default_branch, remote_name); if let Some(commit_id) = default_branch_remote_ref.target.as_normal().cloned() { let mut checkout_tx = workspace_command.start_transaction(); // For convenience, create local bookmark as Git would do. checkout_tx .repo_mut() .track_remote_bookmark(default_branch, remote_name); if let Ok(commit) = checkout_tx.repo().store().get_commit(&commit_id) { checkout_tx.check_out(&commit)?; } checkout_tx.finish(ui, "check out git remote's default branch")?; } } Ok(())} fn do_git_clone( ui: &mut Ui, command: &CommandHelper, colocate: bool, depth: Option<NonZeroU32>, remote_name: &str, source: &str, wc_path: &Path,) -> Result<(WorkspaceCommandHelper, GitFetchStats), CommandError> { let (workspace, repo) = if colocate { Workspace::init_colocated_git(command.settings(), wc_path)? } else { Workspace::init_internal_git(command.settings(), wc_path)? }; let git_repo = get_git_repo(repo.store())?; writeln!( ui.status(), r#"Fetching into new repo in "{}""#, wc_path.display() )?; let mut workspace_command = command.for_workable_repo(ui, workspace, repo)?; maybe_add_gitignore(&workspace_command)?; git_repo.remote(remote_name, source).unwrap(); let mut fetch_tx = workspace_command.start_transaction(); let stats = with_remote_git_callbacks(ui, None, |cb| { git::fetch( fetch_tx.repo_mut(), &git_repo, remote_name, &[StringPattern::everything()], cb, &command.settings().git_settings(), depth, ) }) .map_err(|err| match err { GitFetchError::NoSuchRemote(_) => { panic!("shouldn't happen as we just created the git remote") } GitFetchError::GitImportError(err) => CommandError::from(err), GitFetchError::InternalGitError(err) => map_git_error(err), GitFetchError::InvalidBranchPattern => { unreachable!("we didn't provide any globs") } })?; print_git_import_stats(ui, fetch_tx.repo(), &stats.import_stats, true)?; fetch_tx.finish(ui, "fetch from git remote into empty repo")?; Ok((workspace_command, stats))}
// 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.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::fs;
use std::io;
use std::io::Write;
use std::num::NonZeroU32;
use std::path::Path;
use std::path::PathBuf;
use jj_lib::git;
use jj_lib::git::GitFetchError;
use jj_lib::git::GitFetchStats;
use jj_lib::repo::Repo;
use jj_lib::str_util::StringPattern;
use jj_lib::workspace::Workspace;
use crate::cli_util::CommandHelper;
use crate::cli_util::WorkspaceCommandHelper;
use crate::command_error::cli_error;
use crate::command_error::user_error;
use crate::command_error::user_error_with_message;
use crate::command_error::CommandError;
use crate::commands::git::maybe_add_gitignore;
use crate::config::write_config_value_to_file;
use crate::config::ConfigNamePathBuf;
use crate::git_util::get_git_repo;
use crate::git_util::map_git_error;
use crate::git_util::print_git_import_stats;
use crate::git_util::with_remote_git_callbacks;
use crate::ui::Ui;
/// Create a new repo backed by a clone of a Git repo
///
/// The Git repo will be a bare git repo stored inside the `.jj/` directory.
#[derive(clap::Args, Clone, Debug)]
pub struct GitCloneArgs {
/// URL or path of the Git repo to clone
#[arg(value_hint = clap::ValueHint::DirPath)]
source: String,
/// Specifies the target directory for the Jujutsu repository clone.
/// If not provided, defaults to a directory named after the last component
/// of the source URL. The full directory path will be created if it
/// doesn't exist.
#[arg(value_hint = clap::ValueHint::DirPath)]
destination: Option<String>,
/// Name of the newly created remote
#[arg(long = "remote", default_value = "origin")]
remote_name: String,
/// Whether or not to colocate the Jujutsu repo with the git repo
#[arg(long)]
colocate: bool,
/// Create a shallow clone of the given depth
#[arg(long)]
depth: Option<NonZeroU32>,
}
fn absolute_git_source(cwd: &Path, source: &str) -> String {
// Git appears to turn URL-like source to absolute path if local git directory
// exits, and fails because '$PWD/https' is unsupported protocol. Since it would
// be tedious to copy the exact git (or libgit2) behavior, we simply assume a
// source containing ':' is a URL, SSH remote, or absolute path with Windows
// drive letter.
if !source.contains(':') && Path::new(source).exists() {
// It's less likely that cwd isn't utf-8, so just fall back to original source.
cwd.join(source)
.into_os_string()
.into_string()
.unwrap_or_else(|_| source.to_owned())
} else {
source.to_owned()
}
}
fn clone_destination_for_source(source: &str) -> Option<&str> {
let destination = source.strip_suffix(".git").unwrap_or(source);
let destination = destination.strip_suffix('/').unwrap_or(destination);
destination
.rsplit_once(&['/', '\\', ':'][..])
.map(|(_, name)| name)
}
fn is_empty_dir(path: &Path) -> bool {
if let Ok(mut entries) = path.read_dir() {
entries.next().is_none()
} else {
false
}
}
pub fn cmd_git_clone(
ui: &mut Ui,
command: &CommandHelper,
args: &GitCloneArgs,
) -> Result<(), CommandError> {
let remote_name = &args.remote_name;
if command.global_args().at_operation.is_some() {
return Err(cli_error("--at-op is not respected"));
}
let source = absolute_git_source(command.cwd(), &args.source);
let wc_path_str = args
.destination
.as_deref()
.or_else(|| clone_destination_for_source(&source))
.ok_or_else(|| user_error("No destination specified and wasn't able to guess it"))?;
let wc_path = command.cwd().join(wc_path_str);
let wc_path_existed = wc_path.exists();
if wc_path_existed && !is_empty_dir(&wc_path) {
return Err(user_error(
"Destination path exists and is not an empty directory",
));
}
// will create a tree dir in case if was deleted after last check
fs::create_dir_all(&wc_path)
.map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?;
// Canonicalize because fs::remove_dir_all() doesn't seem to like e.g.
// `/some/path/.`
let canonical_wc_path: PathBuf = wc_path
.canonicalize()
.map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?;
let clone_result = do_git_clone(
ui,
command,
args.colocate,
args.depth,
remote_name,
&source,
&canonical_wc_path,
);
if clone_result.is_err() {
let clean_up_dirs = || -> io::Result<()> {
fs::remove_dir_all(canonical_wc_path.join(".jj"))?;
if args.colocate {
fs::remove_dir_all(canonical_wc_path.join(".git"))?;
}
if !wc_path_existed {
fs::remove_dir(&canonical_wc_path)?;
}
Ok(())
};
if let Err(err) = clean_up_dirs() {
writeln!(
ui.warning_default(),
"Failed to clean up {}: {}",
canonical_wc_path.display(),
err
)
.ok();
}
}
let (mut workspace_command, stats) = clone_result?;
if let Some(default_branch) = &stats.default_branch {
// Set repository level `trunk()` alias to the default remote branch.
let config_path = workspace_command.repo_path().join("config.toml");
write_config_value_to_file(
&ConfigNamePathBuf::from_iter(["revset-aliases", "trunk()"]),
format!("{default_branch}@{remote_name}").into(),
&config_path,
)?;
writeln!(
ui.status(),
"Setting the revset alias \"trunk()\" to \"{default_branch}@{remote_name}\""
)?;
let default_branch_remote_ref = workspace_command
.repo()
.view()
.get_remote_bookmark(default_branch, remote_name);
if let Some(commit_id) = default_branch_remote_ref.target.as_normal().cloned() {
let mut checkout_tx = workspace_command.start_transaction();
// For convenience, create local bookmark as Git would do.
checkout_tx
.repo_mut()
.track_remote_bookmark(default_branch, remote_name);
if let Ok(commit) = checkout_tx.repo().store().get_commit(&commit_id) {
checkout_tx.check_out(&commit)?;
}
checkout_tx.finish(ui, "check out git remote's default branch")?;
}
}
Ok(())
}
fn do_git_clone(
ui: &mut Ui,
command: &CommandHelper,
colocate: bool,
depth: Option<NonZeroU32>,
remote_name: &str,
source: &str,
wc_path: &Path,
) -> Result<(WorkspaceCommandHelper, GitFetchStats), CommandError> {
let (workspace, repo) = if colocate {
Workspace::init_colocated_git(command.settings(), wc_path)?
} else {
Workspace::init_internal_git(command.settings(), wc_path)?
};
let git_repo = get_git_repo(repo.store())?;
writeln!(
ui.status(),
r#"Fetching into new repo in "{}""#,
wc_path.display()
)?;
let mut workspace_command = command.for_workable_repo(ui, workspace, repo)?;
maybe_add_gitignore(&workspace_command)?;
git_repo.remote(remote_name, source).unwrap();
let mut fetch_tx = workspace_command.start_transaction();
let stats = with_remote_git_callbacks(ui, None, |cb| {
git::fetch(
fetch_tx.repo_mut(),
&git_repo,
remote_name,
&[StringPattern::everything()],
cb,
&command.settings().git_settings(),
depth,
)
})
.map_err(|err| match err {
GitFetchError::NoSuchRemote(_) => {
panic!("shouldn't happen as we just created the git remote")
}
GitFetchError::GitImportError(err) => CommandError::from(err),
GitFetchError::InternalGitError(err) => map_git_error(err),
GitFetchError::InvalidBranchPattern => {
unreachable!("we didn't provide any globs")
}
})?;
print_git_import_stats(ui, fetch_tx.repo(), &stats.import_stats, true)?;
fetch_tx.finish(ui, "fetch from git remote into empty repo")?;
Ok((workspace_command, stats))
}