// 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::io; use std::io::Write as _; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use indoc::writedoc; use itertools::Itertools as _; use jj_lib::file_util; use jj_lib::git; use jj_lib::git::GitImportOptions; use jj_lib::git::GitRefKind; use jj_lib::git::GitSettings; use jj_lib::git::parse_git_ref; use jj_lib::repo::ReadonlyRepo; use jj_lib::repo::Repo as _; use jj_lib::view::View; use jj_lib::workspace::Workspace; use super::ObjectHash; use super::RepoPresets; use super::write_repo_presets; use crate::cli_util::CommandHelper; use crate::cli_util::shell_quote; use crate::cli_util::start_repo_transaction; use crate::command_error::CommandError; use crate::command_error::cli_error; use crate::command_error::internal_error; use crate::command_error::user_error; use crate::command_error::user_error_with_message; use crate::commands::git::maybe_add_gitignore; use crate::config::ConfigEnv; use crate::formatter::FormatterExt as _; use crate::git_util::is_colocated_git_workspace; use crate::git_util::load_git_import_options; use crate::git_util::print_git_export_stats; use crate::git_util::print_git_import_stats_summary; use crate::ui::Ui; /// Create a new Git backed repo. #[derive(clap::Args, Clone, Debug)] pub struct GitInitArgs { /// The destination directory where the `jj` repo will be created. /// If the directory does not exist, it will be created. /// If no directory is given, the current directory is used. /// /// By default the `git` repo is under `$destination/.jj` #[arg(default_value = ".", value_hint = clap::ValueHint::DirPath)] destination: String, /// Colocate the Jujutsu repo with the git repo /// /// Specifies that the `jj` repo should also be a valid `git` repo, allowing /// the use of both `jj` and `git` commands in the same directory. /// /// The repository will contain a `.git` dir in the top-level. Regular Git /// tools will be able to operate on the repo. /// /// **This is the default**, and this option has no effect, unless the /// [git.colocate config] is set to `false`. /// /// This option is mutually exclusive with `--git-repo`. /// /// [git.colocate config]: /// https://docs.jj-vcs.dev/latest/config/#default-colocation #[arg(long, conflicts_with = "git_repo")] colocate: bool, /// Disable colocation of the Jujutsu repo with the git repo /// /// Prevent Git tools that are unaware of `jj` and regular Git commands from /// operating on the repo. The Git repository that stores most of the repo /// data will be hidden inside a sub-directory of the `.jj` directory. /// /// See [colocation docs] for some minor advantages of non-colocated /// workspaces. /// /// [colocation docs]: /// https://docs.jj-vcs.dev/latest/git-compatibility/#colocated-jujutsugit-repos #[arg(long, conflicts_with = "colocate")] no_colocate: bool, /// Object hash algorithm for the newly created Git repository. /// /// Corresponds to `git init`'s `--object-format` option. If not given, the /// [git.object-hash config] determines the default value. /// /// Note that the object hash cannot currently be changed for an existing /// repo, and not all code forges support SHA-256 repositories yet. See also /// Git's [hash-function-transition] document for an in-depth explanation of /// the migration towards stronger hash functions. /// /// [git.object-hash config]: /// https://docs.jj-vcs.dev/latest/config/#default-object-hash-format /// [hash-function-transition]: /// https://git-scm.com/docs/hash-function-transition #[arg(long, value_enum, conflicts_with = "git_repo")] object_hash: Option, /// Specifies a path to an **existing** git repository to be /// used as the backing git repo for the newly created `jj` repo. /// /// If the specified `--git-repo` path happens to be the same as /// the `jj` repo path (both .jj and .git directories are in the /// same working directory), then both `jj` and `git` commands /// will work on the same repo. This is called a colocated workspace. /// /// This option is mutually exclusive with `--colocate`, and so if passed, /// turns colocation off. #[arg( long, conflicts_with_all = ["colocate", "object_hash"], value_hint = clap::ValueHint::DirPath, )] git_repo: Option, } pub async fn cmd_git_init( ui: &mut Ui, command: &CommandHelper, args: &GitInitArgs, ) -> Result<(), CommandError> { if command.global_args().no_integrate_operation { return Err(cli_error("--no-integrate-operation is not respected")); } if command.global_args().ignore_working_copy { return Err(cli_error("--ignore-working-copy is not respected")); } if command.global_args().at_operation.is_some() { return Err(cli_error("--at-op is not respected")); } let cwd = command.cwd(); let wc_path = cwd.join(&args.destination); let wc_path = file_util::create_or_reuse_dir(&wc_path) .and_then(|_| dunce::canonicalize(wc_path)) .map_err(|e| user_error_with_message("Failed to create workspace", e))?; let colocate = if command.settings().get_bool("git.colocate")? { !args.no_colocate } else { args.colocate }; let object_hash = args.object_hash.map_or_else( || command.settings().get::("git.object-hash"), Result::Ok, )?; do_init( ui, command, &wc_path, colocate, object_hash.into(), args.git_repo.as_deref(), ) .await?; let relative_wc_path = file_util::relative_path(cwd, &wc_path); writeln!( ui.status(), r#"Initialized repo in "{}""#, relative_wc_path.display() )?; Ok(()) } async fn do_init( ui: &mut Ui, command: &CommandHelper, workspace_root: &Path, colocate: bool, object_hash: gix::hash::Kind, git_repo: Option<&str>, ) -> Result<(), CommandError> { #[derive(Clone, Debug)] enum GitInitMode { Colocate, External(PathBuf), Internal, } let colocated_git_repo_path = workspace_root.join(".git"); let init_mode = if let Some(path_str) = git_repo { let mut git_repo_path = command.cwd().join(path_str); if !git_repo_path.ends_with(".git") { git_repo_path.push(".git"); // Undo if .git doesn't exist - likely a bare repo. if !git_repo_path.exists() { git_repo_path.pop(); } } GitInitMode::External(git_repo_path) } else if colocate { if colocated_git_repo_path.exists() { // Refuse to colocate inside a Git worktree if is_linked_git_worktree(workspace_root) { return Err( user_error("Cannot create a colocated jj repo inside a Git worktree.").hinted( "Run `jj git init` in the main Git repository instead, or use `jj \ workspace add` to create additional jj workspaces.", ), ); } GitInitMode::External(colocated_git_repo_path) } else { GitInitMode::Colocate } } else { if colocated_git_repo_path.exists() { return Err(user_error( "Did not create a jj repo because there is an existing Git repo in this directory.", ) .hinted( "To create a repo backed by the existing Git repo, run `jj git init --colocate` \ instead.", )); } GitInitMode::Internal }; let (settings, config_env) = command.settings_for_new_workspace(ui, workspace_root)?; match &init_mode { GitInitMode::Colocate => { let (workspace, repo) = Workspace::init_colocated_git(&settings, workspace_root, object_hash).await?; let workspace_command = command.for_workable_repo(ui, workspace, repo)?; maybe_add_gitignore(&workspace_command)?; } GitInitMode::External(git_repo_path) => { let (workspace, repo) = Workspace::init_external_git(&settings, workspace_root, git_repo_path).await?; // Import refs first so all the reachable commits are indexed in // chronological order. let colocated = is_colocated_git_workspace(&workspace)?; let repo = init_git_refs(ui, repo, command.string_args(), &workspace, colocated).await?; let mut workspace_command = command.for_workable_repo(ui, workspace, repo)?; maybe_add_gitignore(&workspace_command)?; workspace_command.maybe_snapshot(ui).await?; maybe_set_repository_level_trunk_alias( ui, &git::get_git_repo(workspace_command.repo().store())?, &config_env, )?; if !workspace_command.working_copy_shared_with_git() { let mut tx = workspace_command.start_transaction(); if let Some(git_head_commit) = git::import_head_commit(tx.repo_mut()).await? { tx.check_out(&git_head_commit)?; } if tx.repo().has_changes() { tx.finish(ui, "import git head").await?; } } print_trackable_remote_bookmarks(ui, workspace_command.repo().view())?; } GitInitMode::Internal => { Workspace::init_internal_git(&settings, workspace_root, object_hash).await?; } } Ok(()) } /// Imports branches and tags from the underlying Git repo, exports changes if /// the repo is colocated. /// /// This is similar to `WorkspaceCommandHelper::import_git_refs()`, but never /// moves the Git HEAD to the working copy parent. async fn init_git_refs( ui: &mut Ui, repo: Arc, string_args: &[String], workspace: &Workspace, colocated: bool, ) -> Result, CommandError> { let git_settings = GitSettings::from_settings(repo.settings())?; let remote_settings = repo.settings().remote_settings()?; let import_options = GitImportOptions { // There should be no old refs to abandon, but enforce it. abandon_unreachable_commits: false, // There may be a large number of new commits. Don't record synthetic // predecessors. record_synthetic_predecessors: false, ..load_git_import_options(ui, &git_settings, &remote_settings)? }; let mut tx = start_repo_transaction(&repo, workspace.workspace_name(), string_args); let stats = git::import_refs(tx.repo_mut(), &import_options).await?; print_git_import_stats_summary(ui, &stats)?; if !tx.repo().has_changes() { return Ok(repo); } if colocated { // If remotes..auto-track-bookmarks is set, local bookmarks could // be created for the imported remote branches. let stats = git::export_refs(tx.repo_mut())?; print_git_export_stats(ui, &stats)?; } let repo = tx.commit("import git refs").await?; writeln!( ui.status(), "Done importing changes from the underlying Git repo." )?; Ok(repo) } // Set repository level `trunk()` alias to the default branch. // Checks "upstream" first, then "origin" as fallback. pub fn maybe_set_repository_level_trunk_alias( ui: &Ui, git_repo: &gix::Repository, config_env: &ConfigEnv, ) -> Result<(), CommandError> { // Try "upstream" first, then fall back to "origin" for remote in ["upstream", "origin"] { let ref_name = format!("refs/remotes/{remote}/HEAD"); if let Some(reference) = git_repo .try_find_reference(&ref_name) .map_err(internal_error)? { // Found a HEAD reference for this remote. Even if we can't parse it, // we should stop here and not try other remotes because it doesn't // really make sense if "origin" were to be set as the default if we // know "upstream" exists. if let Some(reference_name) = reference.target().try_name() && let Some((GitRefKind::Bookmark, symbol)) = str::from_utf8(reference_name.as_bstr()) .ok() .and_then(|name| parse_git_ref(name.as_ref())) { // TODO: Can we assume the symbolic target points to the same remote? let symbol = symbol.name.to_remote_symbol(remote.as_ref()); write_repo_presets( ui, config_env, RepoPresets { remote: remote.as_ref(), fetch_bookmarks: None, fetch_tags: None, trunk: Some(symbol), }, )?; } return Ok(()); } } Ok(()) } fn print_trackable_remote_bookmarks(ui: &Ui, view: &View) -> io::Result<()> { let remote_bookmark_symbols = view .bookmarks() .filter(|(_, bookmark_target)| bookmark_target.local_target.is_present()) .flat_map(|(name, bookmark_target)| { bookmark_target .remote_refs .into_iter() .filter(|&(_, remote_ref)| !remote_ref.is_tracked()) .map(move |(remote, _)| name.to_remote_symbol(remote)) }) .collect_vec(); if remote_bookmark_symbols.is_empty() { return Ok(()); } if let Some(mut formatter) = ui.status_formatter() { writeln!( formatter.labeled("hint").with_heading("Hint: "), "The following remote bookmarks aren't associated with the existing local bookmarks:" )?; for symbol in &remote_bookmark_symbols { write!(formatter, " ")?; writeln!(formatter.labeled("bookmark"), "{symbol}")?; } writedoc!( formatter.labeled("hint").with_heading("Hint: "), " Run the following command to keep local bookmarks updated on future pulls: jj bookmark track {syms} ", syms = remote_bookmark_symbols .iter() .map(|s| shell_quote(&s.to_string()).into_owned()) .join(" ") )?; } Ok(()) } /// Returns `true` if the path is inside a linked Git worktree. fn is_linked_git_worktree(workspace_root: &Path) -> bool { let Ok(repo) = gix::open(workspace_root) else { return false; }; // In linked worktrees, git_dir points to .git/worktrees/ while // common_dir points to the main .git directory repo.git_dir() != repo.common_dir() }