381 lines · 13.5 KB · Rust
RawDownload
// 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 as _;use std::num::NonZeroU32;use std::path::Path; use jj_lib::git;use jj_lib::git::FetchTagsOverride;use jj_lib::git::GitFetch;use jj_lib::git::expand_fetch_refspecs;use jj_lib::ref_name::RefNameBuf;use jj_lib::ref_name::RemoteName;use jj_lib::ref_name::RemoteNameBuf;use jj_lib::repo::Repo as _;use jj_lib::str_util::StringPattern;use jj_lib::workspace::Workspace; use super::write_repository_level_trunk_alias;use crate::cli_util::CommandHelper;use crate::cli_util::WorkspaceCommandHelper;use crate::command_error::CommandError;use crate::command_error::cli_error;use crate::command_error::user_error;use crate::command_error::user_error_with_message;use crate::commands::git::FetchTagsMode;use crate::commands::git::maybe_add_gitignore;use crate::git_util::absolute_git_url;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    ///    /// Local path will be resolved to absolute form.    #[arg(value_hint = clap::ValueHint::Url)]    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: RemoteNameBuf,    /// Whether or not to colocate the Jujutsu repo with the git repo    #[arg(long)]    colocate: bool,    /// Disable colocation of the Jujutsu repo with the git repo    #[arg(long, conflicts_with = "colocate")]    no_colocate: bool,    /// Create a shallow clone of the given depth    #[arg(long)]    depth: Option<NonZeroU32>,    /// Configure when to fetch tags    ///    /// Unless otherwise specified, the initial clone will fetch all tags,    /// while all subsequent fetches will only fetch included tags.    #[arg(long, value_enum)]    fetch_tags: Option<FetchTagsMode>,    /// Name of the branch to fetch and use as the parent of the working-copy    /// change    ///    /// If not present, all branches are fetched and the repository's default    /// branch is used as parent of the working-copy change.    ///    /// By default, the specified name matches exactly. Use `glob:` prefix to    /// expand `*` as a glob, e.g. `--branch 'glob:push-*'`. Other wildcard    /// characters such as `?` are *not* supported. Can be repeated to specify    /// multiple branches, in which case the first match of the first `--branch`    /// argument is used as the working-copy parent.    #[arg(        long, short,        alias = "bookmark",        value_parser = StringPattern::parse,    )]    branch: Option<Vec<StringPattern>>,} 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_url(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))?;     let colocate = if command.settings().git_settings()?.colocate {        !args.no_colocate    } else {        args.colocate    };     // Canonicalize because fs::remove_dir_all() doesn't seem to like e.g.    // `/some/path/.`    let canonical_wc_path = dunce::canonicalize(&wc_path)        .map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?;     let clone_result = (|| -> Result<_, CommandError> {        let workspace_command = init_workspace(ui, command, &canonical_wc_path, colocate)?;        let mut workspace_command = configure_remote(            ui,            command,            workspace_command,            remote_name,            &source,            // If not explicitly specified on the CLI, configure the remote for only fetching            // included tags for future fetches.            args.fetch_tags.unwrap_or(FetchTagsMode::Included),            args.branch.as_deref(),        )?;        let default_branch = fetch_new_remote(            ui,            &mut workspace_command,            remote_name,            args.branch.as_deref(),            args.depth,            args.fetch_tags,        )?;        Ok((workspace_command, default_branch))    })();    if clone_result.is_err() {        let clean_up_dirs = || -> io::Result<()> {            fs::remove_dir_all(canonical_wc_path.join(".jj"))?;            if 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, (working_branch, working_is_default)) = clone_result?;     if let Some(name) = &working_branch {        let working_symbol = name.to_remote_symbol(remote_name);        if working_is_default {            write_repository_level_trunk_alias(ui, workspace_command.repo_path(), working_symbol)?;        }        let working_branch_remote_ref = workspace_command            .repo()            .view()            .get_remote_bookmark(working_symbol);        if let Some(commit_id) = working_branch_remote_ref.target.as_normal().cloned() {            let mut tx = workspace_command.start_transaction();            if let Ok(commit) = tx.repo().store().get_commit(&commit_id) {                tx.check_out(&commit)?;            }            tx.finish(                ui,                format!("check out git remote's branch: {}", name.as_symbol()),            )?;        }    }     if colocate {        writeln!(            ui.hint_default(),            r"Running `git clean -xdf` will remove `.jj/`!",        )?;    }     Ok(())} fn init_workspace(    ui: &Ui,    command: &CommandHelper,    wc_path: &Path,    colocate: bool,) -> Result<WorkspaceCommandHelper, CommandError> {    let settings = command.settings_for_new_workspace(wc_path)?;    let (workspace, repo) = if colocate {        Workspace::init_colocated_git(&settings, wc_path)?    } else {        Workspace::init_internal_git(&settings, wc_path)?    };    let workspace_command = command.for_workable_repo(ui, workspace, repo)?;    maybe_add_gitignore(&workspace_command)?;    Ok(workspace_command)} fn configure_remote(    ui: &Ui,    command: &CommandHelper,    mut workspace_command: WorkspaceCommandHelper,    remote_name: &RemoteName,    source: &str,    fetch_tags: FetchTagsMode,    branch_patterns: Option<&[StringPattern]>,) -> Result<WorkspaceCommandHelper, CommandError> {    let mut tx = workspace_command.start_transaction();    git::add_remote(        tx.repo_mut(),        remote_name,        source,        fetch_tags.as_fetch_tags(),        branch_patterns,    )?;    tx.finish(ui, format!("add git remote {}", remote_name.as_symbol()))?;    // Reload workspace to apply new remote configuration to    // gix::ThreadSafeRepository behind the store.    let workspace = command.load_workspace_at(        workspace_command.workspace_root(),        workspace_command.settings(),    )?;    let op = workspace        .repo_loader()        .load_operation(workspace_command.repo().op_id())?;    let repo = workspace.repo_loader().load_at(&op)?;    command.for_workable_repo(ui, workspace, repo)} fn fetch_new_remote(    ui: &Ui,    workspace_command: &mut WorkspaceCommandHelper,    remote_name: &RemoteName,    target_branches: Option<&[StringPattern]>,    depth: Option<NonZeroU32>,    fetch_tags: Option<FetchTagsMode>,) -> Result<(Option<RefNameBuf>, bool), CommandError> {    writeln!(        ui.status(),        r#"Fetching into new repo in "{}""#,        workspace_command.workspace_root().display()    )?;    let settings = workspace_command.settings();    let git_settings = settings.git_settings()?;    let should_track_default = settings.get_bool("git.track-default-bookmark-on-clone")?;    let mut tx = workspace_command.start_transaction();    let (default_branch, import_stats) = {        let mut git_fetch = GitFetch::new(tx.repo_mut(), &git_settings)?;         let fetch_refspecs = expand_fetch_refspecs(            remote_name,            target_branches                .unwrap_or(&[StringPattern::everything()])                .to_vec(),        )?;         with_remote_git_callbacks(ui, |cb| {            git_fetch.fetch(                remote_name,                fetch_refspecs,                cb,                depth,                match fetch_tags {                    // If not explicitly specified on the CLI, override the remote                    // configuration and fetch all tags by default since this is                    // the Git default behavior.                    None => Some(FetchTagsOverride::AllTags),                     // Technically by this point the remote should already be                    // configured based on the CLI parameters so we shouldn't *need*                    // to apply an override here but all the cases are expanded here                    // for clarity.                    Some(FetchTagsMode::All) => Some(FetchTagsOverride::AllTags),                    Some(FetchTagsMode::None) => Some(FetchTagsOverride::NoTags),                    Some(FetchTagsMode::Included) => None,                },            )        })?;         let import_stats = git_fetch.import_refs()?;         let default_branch = git_fetch.get_default_branch(remote_name)?;        (default_branch, import_stats)    };     let working_branch = match target_branches {        Some(target_branches) => {            // Ensure that all targets match at least one branch, and record the matching            // branch for the first target            let mut first_match = None;            for pattern in target_branches {                let mut bookmarks = tx.repo().view().remote_bookmarks(remote_name);                let Some((bookmark, _)) =                    bookmarks.find(|(bookmark, _)| pattern.is_match(bookmark.as_str()))                else {                    return Err(user_error(format!(                        "No such branch matching pattern: {pattern}",                    )));                };                if first_match.is_none() {                    first_match = Some(bookmark.to_owned());                }            }            first_match        }        None => default_branch.clone(),    };     let working_is_default = working_branch == default_branch;    if let Some(name) = &working_branch        && working_is_default        && should_track_default    {        // For convenience, create local bookmark as Git would do.        let remote_symbol = name.to_remote_symbol(remote_name);        let remote_ref = tx.repo().get_remote_bookmark(remote_symbol);        if remote_ref.is_present() {            tx.repo_mut().track_remote_bookmark(remote_symbol)?;        }    }    print_git_import_stats(ui, tx.repo(), &import_stats, true)?;    if git_settings.auto_local_bookmark && !should_track_default {        writeln!(            ui.hint_default(),            "`git.track-default-bookmark-on-clone=false` has no effect if \             `git.auto-local-bookmark` is enabled."        )?;    }    tx.finish(ui, "fetch from git remote into empty repo")?;    Ok((working_branch, working_is_default))}