diff --git a/cli/src/commands/git.rs b/cli/src/commands/git.rs deleted file mode 100644 index 4b3888646..000000000 --- a/cli/src/commands/git.rs +++ /dev/null @@ -1,1350 +0,0 @@ -// 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::collections::{HashMap, HashSet}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::{fmt, fs, io}; - -use clap::{ArgGroup, Subcommand}; -use itertools::Itertools; -use jj_lib::backend::TreeValue; -use jj_lib::file_util; -use jj_lib::git::{ - self, parse_gitmodules, GitBranchPushTargets, GitFetchError, GitFetchStats, GitPushError, -}; -use jj_lib::object_id::ObjectId; -use jj_lib::op_store::RefTarget; -use jj_lib::refs::{ - classify_branch_push_action, BranchPushAction, BranchPushUpdate, LocalAndRemoteRef, -}; -use jj_lib::repo::{ReadonlyRepo, Repo}; -use jj_lib::repo_path::RepoPath; -use jj_lib::revset::{self, RevsetExpression, RevsetIteratorExt as _}; -use jj_lib::settings::{ConfigResultExt as _, UserSettings}; -use jj_lib::str_util::StringPattern; -use jj_lib::view::View; -use jj_lib::workspace::Workspace; - -use crate::cli_util::{ - print_trackable_remote_branches, short_change_hash, short_commit_hash, start_repo_transaction, - CommandHelper, RevisionArg, WorkspaceCommandHelper, WorkspaceCommandTransaction, -}; -use crate::command_error::{ - user_error, user_error_with_hint, user_error_with_message, CommandError, -}; -use crate::git_util::{ - get_git_repo, is_colocated_git_workspace, print_failed_git_export, print_git_import_stats, - with_remote_git_callbacks, GitSidebandProgressMessageWriter, -}; -use crate::ui::Ui; - -/// Commands for working with Git remotes and the underlying Git repo -/// -/// For a comparison with Git, including a table of commands, see -/// https://github.com/martinvonz/jj/blob/main/docs/git-comparison.md. -#[derive(Subcommand, Clone, Debug)] -pub enum GitCommand { - #[command(subcommand)] - Remote(GitRemoteCommand), - Init(GitInitArgs), - Fetch(GitFetchArgs), - Clone(GitCloneArgs), - Push(GitPushArgs), - Import(GitImportArgs), - Export(GitExportArgs), - #[command(subcommand, hide = true)] - Submodule(GitSubmoduleCommand), -} - -/// Manage Git remotes -/// -/// The Git repo will be a bare git repo stored inside the `.jj/` directory. -#[derive(Subcommand, Clone, Debug)] -pub enum GitRemoteCommand { - Add(GitRemoteAddArgs), - Remove(GitRemoteRemoveArgs), - Rename(GitRemoteRenameArgs), - List(GitRemoteListArgs), -} - -/// Add a Git remote -#[derive(clap::Args, Clone, Debug)] -pub struct GitRemoteAddArgs { - /// The remote's name - remote: String, - /// The remote's URL - url: String, -} - -/// Remove a Git remote and forget its branches -#[derive(clap::Args, Clone, Debug)] -pub struct GitRemoteRemoveArgs { - /// The remote's name - remote: String, -} - -/// Rename a Git remote -#[derive(clap::Args, Clone, Debug)] -pub struct GitRemoteRenameArgs { - /// The name of an existing remote - old: String, - /// The desired name for `old` - new: String, -} - -/// List Git remotes -#[derive(clap::Args, Clone, Debug)] -pub struct GitRemoteListArgs {} - -/// 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, - - /// 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. - /// - /// This is done by placing the backing git repo into a `.git` directory in - /// the root of the `jj` repo along with the `.jj` directory. If the `.git` - /// directory already exists, all the existing commits will be imported. - /// - /// This option is mutually exclusive with `--git-repo`. - #[arg(long, conflicts_with = "git_repo")] - colocate: bool, - - /// 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 co-located repo. - /// - /// This option is mutually exclusive with `--colocate`. - #[arg(long, conflicts_with = "colocate", value_hint = clap::ValueHint::DirPath)] - git_repo: Option, -} - -/// Fetch from a Git remote -/// -/// If a working-copy commit gets abandoned, it will be given a new, empty -/// commit. This is true in general; it is not specific to this command. -#[derive(clap::Args, Clone, Debug)] -pub struct GitFetchArgs { - /// Fetch only some of the branches - /// - /// By default, the specified name matches exactly. Use `glob:` prefix to - /// expand `*` as a glob. The other wildcard characters aren't supported. - #[arg(long, short, default_value = "glob:*", value_parser = StringPattern::parse)] - branch: Vec, - /// The remote to fetch from (only named remotes are supported, can be - /// repeated) - #[arg(long = "remote", value_name = "remote")] - remotes: Vec, - /// Fetch from all remotes - #[arg(long, conflicts_with = "remotes")] - all_remotes: bool, -} - -/// 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, - /// The directory to write the Jujutsu repo to - #[arg(value_hint = clap::ValueHint::DirPath)] - destination: Option, - /// Whether or not to colocate the Jujutsu repo with the git repo - #[arg(long)] - colocate: bool, -} - -/// Push to a Git remote -/// -/// By default, pushes any branches pointing to -/// `remote_branches(remote=)..@`. Use `--branch` to push specific -/// branches. Use `--all` to push all branches. Use `--change` to generate -/// branch names based on the change IDs of specific commits. -/// -/// Before the command actually moves, creates, or deletes a remote branch, it -/// makes several [safety checks]. If there is a problem, you may need to run -/// `jj git fetch --remote ` and/or resolve some [branch -/// conflicts]. -/// -/// [safety checks]: -/// https://martinvonz.github.io/jj/latest/branches/#pushing-branches-safety-checks -/// -/// [branch conflicts]: -/// https://martinvonz.github.io/jj/latest/branches/#conflicts - -#[derive(clap::Args, Clone, Debug)] -#[command(group(ArgGroup::new("specific").args(&["branch", "change", "revisions"]).multiple(true)))] -#[command(group(ArgGroup::new("what").args(&["all", "deleted", "tracked"]).conflicts_with("specific")))] -pub struct GitPushArgs { - /// The remote to push to (only named remotes are supported) - #[arg(long)] - remote: Option, - /// Push only this branch, or branches matching a pattern (can be repeated) - /// - /// By default, the specified name matches exactly. Use `glob:` prefix to - /// select branches by wildcard pattern. For details, see - /// https://martinvonz.github.io/jj/latest/revsets#string-patterns. - #[arg(long, short, value_parser = StringPattern::parse)] - branch: Vec, - /// Push all branches (including deleted branches) - #[arg(long)] - all: bool, - /// Push all tracked branches (including deleted branches) - /// - /// This usually means that the branch was already pushed to or fetched from - /// the relevant remote. For details, see - /// https://martinvonz.github.io/jj/latest/branches#remotes-and-tracked-branches - #[arg(long)] - tracked: bool, - /// Push all deleted branches - /// - /// Only tracked branches can be successfully deleted on the remote. A - /// warning will be printed if any untracked branches on the remote - /// correspond to missing local branches. - #[arg(long)] - deleted: bool, - /// Allow pushing commits with empty descriptions - #[arg(long)] - allow_empty_description: bool, - /// Push branches pointing to these commits (can be repeated) - #[arg(long, short)] - revisions: Vec, - /// Push this commit by creating a branch based on its change ID (can be - /// repeated) - #[arg(long, short)] - change: Vec, - /// Only display what will change on the remote - #[arg(long)] - dry_run: bool, -} - -/// Update repo with changes made in the underlying Git repo -/// -/// If a working-copy commit gets abandoned, it will be given a new, empty -/// commit. This is true in general; it is not specific to this command. -#[derive(clap::Args, Clone, Debug)] -pub struct GitImportArgs {} - -/// Update the underlying Git repo with changes made in the repo -#[derive(clap::Args, Clone, Debug)] -pub struct GitExportArgs {} - -/// FOR INTERNAL USE ONLY Interact with git submodules -#[derive(Subcommand, Clone, Debug)] -pub enum GitSubmoduleCommand { - /// Print the relevant contents from .gitmodules. For debugging purposes - /// only. - PrintGitmodules(GitSubmodulePrintGitmodulesArgs), -} - -/// Print debugging info about Git submodules -#[derive(clap::Args, Clone, Debug)] -#[command(hide = true)] -pub struct GitSubmodulePrintGitmodulesArgs { - /// Read .gitmodules from the given revision. - #[arg(long, short = 'r', default_value = "@")] - revisions: RevisionArg, -} - -fn make_branch_term(branch_names: &[impl fmt::Display]) -> String { - match branch_names { - [branch_name] => format!("branch {}", branch_name), - branch_names => format!("branches {}", branch_names.iter().join(", ")), - } -} - -fn map_git_error(err: git2::Error) -> CommandError { - if err.class() == git2::ErrorClass::Ssh { - let hint = - if err.code() == git2::ErrorCode::Certificate && std::env::var_os("HOME").is_none() { - "The HOME environment variable is not set, and might be required for Git to \ - successfully load certificates. Try setting it to the path of a directory that \ - contains a `.ssh` directory." - } else { - "Jujutsu uses libssh2, which doesn't respect ~/.ssh/config. Does `ssh -F \ - /dev/null` to the host work?" - }; - - user_error_with_hint(err, hint) - } else { - user_error(err.to_string()) - } -} - -pub fn maybe_add_gitignore(workspace_command: &WorkspaceCommandHelper) -> Result<(), CommandError> { - if workspace_command.working_copy_shared_with_git() { - std::fs::write( - workspace_command - .workspace_root() - .join(".jj") - .join(".gitignore"), - "/*\n", - ) - .map_err(|e| user_error_with_message("Failed to write .jj/.gitignore file", e)) - } else { - Ok(()) - } -} - -fn cmd_git_remote_add( - ui: &mut Ui, - command: &CommandHelper, - args: &GitRemoteAddArgs, -) -> Result<(), CommandError> { - let workspace_command = command.workspace_helper(ui)?; - let repo = workspace_command.repo(); - let git_repo = get_git_repo(repo.store())?; - git::add_remote(&git_repo, &args.remote, &args.url)?; - Ok(()) -} - -fn cmd_git_remote_remove( - ui: &mut Ui, - command: &CommandHelper, - args: &GitRemoteRemoveArgs, -) -> Result<(), CommandError> { - let mut workspace_command = command.workspace_helper(ui)?; - let repo = workspace_command.repo(); - let git_repo = get_git_repo(repo.store())?; - let mut tx = workspace_command.start_transaction(); - git::remove_remote(tx.mut_repo(), &git_repo, &args.remote)?; - if tx.mut_repo().has_changes() { - tx.finish(ui, format!("remove git remote {}", &args.remote)) - } else { - Ok(()) // Do not print "Nothing changed." - } -} - -fn cmd_git_remote_rename( - ui: &mut Ui, - command: &CommandHelper, - args: &GitRemoteRenameArgs, -) -> Result<(), CommandError> { - let mut workspace_command = command.workspace_helper(ui)?; - let repo = workspace_command.repo(); - let git_repo = get_git_repo(repo.store())?; - let mut tx = workspace_command.start_transaction(); - git::rename_remote(tx.mut_repo(), &git_repo, &args.old, &args.new)?; - if tx.mut_repo().has_changes() { - tx.finish( - ui, - format!("rename git remote {} to {}", &args.old, &args.new), - ) - } else { - Ok(()) // Do not print "Nothing changed." - } -} - -fn cmd_git_remote_list( - ui: &mut Ui, - command: &CommandHelper, - _args: &GitRemoteListArgs, -) -> Result<(), CommandError> { - let workspace_command = command.workspace_helper(ui)?; - let repo = workspace_command.repo(); - let git_repo = get_git_repo(repo.store())?; - for remote_name in git_repo.remotes()?.iter().flatten() { - let remote = git_repo.find_remote(remote_name)?; - writeln!( - ui.stdout(), - "{} {}", - remote_name, - remote.url().unwrap_or("") - )?; - } - Ok(()) -} - -pub fn git_init( - ui: &mut Ui, - command: &CommandHelper, - workspace_root: &Path, - colocate: bool, - 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 colocate { - if colocated_git_repo_path.exists() { - GitInitMode::External(colocated_git_repo_path) - } else { - GitInitMode::Colocate - } - } else 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 colocated_git_repo_path.exists() { - return Err(user_error_with_hint( - "Did not create a jj repo because there is an existing Git repo in this directory.", - "To create a repo backed by the existing Git repo, run `jj git init --colocate` \ - instead.", - )); - } - GitInitMode::Internal - }; - - match &init_mode { - GitInitMode::Colocate => { - let (workspace, repo) = - Workspace::init_colocated_git(command.settings(), workspace_root)?; - let workspace_command = command.for_loaded_repo(ui, workspace, repo)?; - maybe_add_gitignore(&workspace_command)?; - } - GitInitMode::External(git_repo_path) => { - let (workspace, repo) = - Workspace::init_external_git(command.settings(), workspace_root, git_repo_path)?; - // Import refs first so all the reachable commits are indexed in - // chronological order. - let colocated = is_colocated_git_workspace(&workspace, &repo); - let repo = init_git_refs(ui, command, repo, colocated)?; - let mut workspace_command = command.for_loaded_repo(ui, workspace, repo)?; - maybe_add_gitignore(&workspace_command)?; - workspace_command.maybe_snapshot(ui)?; - if !workspace_command.working_copy_shared_with_git() { - let mut tx = workspace_command.start_transaction(); - jj_lib::git::import_head(tx.mut_repo())?; - if let Some(git_head_id) = tx.mut_repo().view().git_head().as_normal().cloned() { - let git_head_commit = tx.mut_repo().store().get_commit(&git_head_id)?; - tx.check_out(&git_head_commit)?; - } - if tx.mut_repo().has_changes() { - tx.finish(ui, "import git head")?; - } - } - print_trackable_remote_branches(ui, workspace_command.repo().view())?; - } - GitInitMode::Internal => { - Workspace::init_internal_git(command.settings(), workspace_root)?; - } - } - 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. -fn init_git_refs( - ui: &mut Ui, - command: &CommandHelper, - repo: Arc, - colocated: bool, -) -> Result, CommandError> { - let mut tx = start_repo_transaction(&repo, command.settings(), command.string_args()); - // There should be no old refs to abandon, but enforce it. - let mut git_settings = command.settings().git_settings(); - git_settings.abandon_unreachable_commits = false; - let stats = git::import_some_refs( - tx.mut_repo(), - &git_settings, - // Initial import shouldn't fail because of reserved remote name. - |ref_name| !git::is_reserved_git_remote_ref(ref_name), - )?; - if !tx.mut_repo().has_changes() { - return Ok(repo); - } - print_git_import_stats(ui, tx.repo(), &stats, false)?; - if colocated { - // If git.auto-local-branch = true, local branches could be created for - // the imported remote branches. - let failed_branches = git::export_refs(tx.mut_repo())?; - print_failed_git_export(ui, &failed_branches)?; - } - let repo = tx.commit("import git refs"); - writeln!( - ui.status(), - "Done importing changes from the underlying Git repo." - )?; - Ok(repo) -} - -fn cmd_git_init( - ui: &mut Ui, - command: &CommandHelper, - args: &GitInitArgs, -) -> Result<(), CommandError> { - let cwd = command.cwd(); - let wc_path = cwd.join(&args.destination); - let wc_path = file_util::create_or_reuse_dir(&wc_path) - .and_then(|_| wc_path.canonicalize()) - .map_err(|e| user_error_with_message("Failed to create workspace", e))?; - - git_init( - ui, - command, - &wc_path, - args.colocate, - args.git_repo.as_deref(), - )?; - - let relative_wc_path = file_util::relative_path(cwd, &wc_path); - writeln!( - ui.status(), - r#"Initialized repo in "{}""#, - relative_wc_path.display() - )?; - - Ok(()) -} - -#[tracing::instrument(skip(ui, command))] -fn cmd_git_fetch( - ui: &mut Ui, - command: &CommandHelper, - args: &GitFetchArgs, -) -> Result<(), CommandError> { - let mut workspace_command = command.workspace_helper(ui)?; - let git_repo = get_git_repo(workspace_command.repo().store())?; - let remotes = if args.all_remotes { - get_all_remotes(&git_repo)? - } else if args.remotes.is_empty() { - get_default_fetch_remotes(ui, command.settings(), &git_repo)? - } else { - args.remotes.clone() - }; - let mut tx = workspace_command.start_transaction(); - for remote in &remotes { - let stats = with_remote_git_callbacks(ui, None, |cb| { - git::fetch( - tx.mut_repo(), - &git_repo, - remote, - &args.branch, - cb, - &command.settings().git_settings(), - ) - }) - .map_err(|err| match err { - GitFetchError::InvalidBranchPattern => { - if args - .branch - .iter() - .any(|pattern| pattern.as_exact().map_or(false, |s| s.contains('*'))) - { - user_error_with_hint( - err, - "Prefix the pattern with `glob:` to expand `*` as a glob", - ) - } else { - user_error(err) - } - } - GitFetchError::GitImportError(err) => err.into(), - GitFetchError::InternalGitError(err) => map_git_error(err), - _ => user_error(err), - })?; - print_git_import_stats(ui, tx.repo(), &stats.import_stats, true)?; - } - tx.finish( - ui, - format!("fetch from git remote(s) {}", remotes.iter().join(",")), - )?; - Ok(()) -} - -fn get_single_remote(git_repo: &git2::Repository) -> Result, CommandError> { - let git_remotes = git_repo.remotes()?; - Ok(match git_remotes.len() { - 1 => git_remotes.get(0).map(ToOwned::to_owned), - _ => None, - }) -} - -const DEFAULT_REMOTE: &str = "origin"; - -fn get_default_fetch_remotes( - ui: &Ui, - settings: &UserSettings, - git_repo: &git2::Repository, -) -> Result, CommandError> { - const KEY: &str = "git.fetch"; - if let Ok(remotes) = settings.config().get(KEY) { - Ok(remotes) - } else if let Some(remote) = settings.config().get_string(KEY).optional()? { - Ok(vec![remote]) - } else if let Some(remote) = get_single_remote(git_repo)? { - // if nothing was explicitly configured, try to guess - if remote != DEFAULT_REMOTE { - writeln!( - ui.hint_default(), - "Fetching from the only existing remote: {remote}" - )?; - } - Ok(vec![remote]) - } else { - Ok(vec![DEFAULT_REMOTE.to_owned()]) - } -} - -fn get_all_remotes(git_repo: &git2::Repository) -> Result, CommandError> { - let git_remotes = git_repo.remotes()?; - Ok(git_remotes - .iter() - .filter_map(|x| x.map(ToOwned::to_owned)) - .collect()) -} - -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 - } -} - -fn cmd_git_clone( - ui: &mut Ui, - command: &CommandHelper, - args: &GitCloneArgs, -) -> Result<(), CommandError> { - let remote_name = "origin"; - 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 = match fs::create_dir(&wc_path) { - Ok(()) => false, - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => true, - Err(err) => { - return Err(user_error_with_message( - format!("Failed to create {wc_path_str}"), - err, - )); - } - }; - if wc_path_existed && !is_empty_dir(&wc_path) { - return Err(user_error( - "Destination path exists and is not an empty directory", - )); - } - - // 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, - 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 { - let default_branch_remote_ref = workspace_command - .repo() - .view() - .get_remote_branch(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 branch as Git would do. - checkout_tx - .mut_repo() - .track_remote_branch(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, - 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_loaded_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.mut_repo(), - &git_repo, - remote_name, - &[StringPattern::everything()], - cb, - &command.settings().git_settings(), - ) - }) - .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)) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum BranchMoveDirection { - Forward, - Backward, - Sideways, -} - -fn cmd_git_push( - ui: &mut Ui, - command: &CommandHelper, - args: &GitPushArgs, -) -> Result<(), CommandError> { - let mut workspace_command = command.workspace_helper(ui)?; - let git_repo = get_git_repo(workspace_command.repo().store())?; - - let remote = if let Some(name) = &args.remote { - name.clone() - } else { - get_default_push_remote(ui, command.settings(), &git_repo)? - }; - - let repo = workspace_command.repo().clone(); - let mut tx = workspace_command.start_transaction(); - let tx_description; - let mut branch_updates = vec![]; - if args.all { - for (branch_name, targets) in repo.view().local_remote_branches(&remote) { - match classify_branch_update(branch_name, &remote, targets) { - Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), - Ok(None) => {} - Err(reason) => reason.print(ui)?, - } - } - tx_description = format!("push all branches to git remote {remote}"); - } else if args.tracked { - for (branch_name, targets) in repo.view().local_remote_branches(&remote) { - if !targets.remote_ref.is_tracking() { - continue; - } - match classify_branch_update(branch_name, &remote, targets) { - Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), - Ok(None) => {} - Err(reason) => reason.print(ui)?, - } - } - tx_description = format!("push all tracked branches to git remote {remote}"); - } else if args.deleted { - for (branch_name, targets) in repo.view().local_remote_branches(&remote) { - if targets.local_target.is_present() { - continue; - } - match classify_branch_update(branch_name, &remote, targets) { - Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), - Ok(None) => {} - Err(reason) => reason.print(ui)?, - } - } - tx_description = format!("push all deleted branches to git remote {remote}"); - } else { - let mut seen_branches: HashSet<&str> = HashSet::new(); - - // Process --change branches first because matching branches can be moved. - let change_branch_names = update_change_branches( - ui, - &mut tx, - &args.change, - &command.settings().push_branch_prefix(), - )?; - let change_branches = change_branch_names.iter().map(|branch_name| { - let targets = LocalAndRemoteRef { - local_target: tx.repo().view().get_local_branch(branch_name), - remote_ref: tx.repo().view().get_remote_branch(branch_name, &remote), - }; - (branch_name.as_ref(), targets) - }); - let branches_by_name = find_branches_to_push(repo.view(), &args.branch, &remote)?; - for (branch_name, targets) in change_branches.chain(branches_by_name.iter().copied()) { - if !seen_branches.insert(branch_name) { - continue; - } - match classify_branch_update(branch_name, &remote, targets) { - Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), - Ok(None) => writeln!( - ui.status(), - "Branch {branch_name}@{remote} already matches {branch_name}", - )?, - Err(reason) => return Err(reason.into()), - } - } - - let use_default_revset = - args.branch.is_empty() && args.change.is_empty() && args.revisions.is_empty(); - let branches_targeted = find_branches_targeted_by_revisions( - ui, - tx.base_workspace_helper(), - &remote, - &args.revisions, - use_default_revset, - )?; - for &(branch_name, targets) in &branches_targeted { - if !seen_branches.insert(branch_name) { - continue; - } - match classify_branch_update(branch_name, &remote, targets) { - Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), - Ok(None) => {} - Err(reason) => reason.print(ui)?, - } - } - - tx_description = format!( - "push {} to git remote {}", - make_branch_term( - &branch_updates - .iter() - .map(|(branch, _)| branch.as_str()) - .collect_vec() - ), - &remote - ); - } - if branch_updates.is_empty() { - writeln!(ui.status(), "Nothing changed.")?; - return Ok(()); - } - - let mut branch_push_direction = HashMap::new(); - for (branch_name, update) in &branch_updates { - let BranchPushUpdate { - old_target: Some(old_target), - new_target: Some(new_target), - } = update - else { - continue; - }; - assert_ne!(old_target, new_target); - branch_push_direction.insert( - branch_name.to_string(), - if repo.index().is_ancestor(old_target, new_target) { - BranchMoveDirection::Forward - } else if repo.index().is_ancestor(new_target, old_target) { - BranchMoveDirection::Backward - } else { - BranchMoveDirection::Sideways - }, - ); - } - - // Check if there are conflicts in any commits we're about to push that haven't - // already been pushed. - let new_heads = branch_updates - .iter() - .filter_map(|(_, update)| update.new_target.clone()) - .collect_vec(); - let mut old_heads = repo - .view() - .remote_branches(&remote) - .flat_map(|(_, old_head)| old_head.target.added_ids()) - .cloned() - .collect_vec(); - if old_heads.is_empty() { - old_heads.push(repo.store().root_commit_id().clone()); - } - for commit in revset::walk_revs(repo.as_ref(), &new_heads, &old_heads)? - .iter() - .commits(repo.store()) - { - let commit = commit?; - let mut reasons = vec![]; - if commit.description().is_empty() && !args.allow_empty_description { - reasons.push("it has no description"); - } - if commit.author().name.is_empty() - || commit.author().name == UserSettings::USER_NAME_PLACEHOLDER - || commit.author().email.is_empty() - || commit.author().email == UserSettings::USER_EMAIL_PLACEHOLDER - || commit.committer().name.is_empty() - || commit.committer().name == UserSettings::USER_NAME_PLACEHOLDER - || commit.committer().email.is_empty() - || commit.committer().email == UserSettings::USER_EMAIL_PLACEHOLDER - { - reasons.push("it has no author and/or committer set"); - } - if commit.has_conflict()? { - reasons.push("it has conflicts"); - } - if !reasons.is_empty() { - return Err(user_error(format!( - "Won't push commit {} since {}", - short_commit_hash(commit.id()), - reasons.join(" and ") - ))); - } - } - - writeln!(ui.status(), "Branch changes to push to {}:", &remote)?; - for (branch_name, update) in &branch_updates { - match (&update.old_target, &update.new_target) { - (Some(old_target), Some(new_target)) => { - let old = short_commit_hash(old_target); - let new = short_commit_hash(new_target); - // TODO(ilyagr): Add color. Once there is color, "Move branch ... sideways" may - // read more naturally than "Move sideways branch ...". Without color, it's hard - // to see at a glance if one branch among many was moved sideways (say). - // TODO: People on Discord suggest "Move branch ... forward by n commits", - // possibly "Move branch ... sideways (X forward, Y back)". - let msg = match branch_push_direction.get(branch_name).unwrap() { - BranchMoveDirection::Forward => { - format!("Move forward branch {branch_name} from {old} to {new}") - } - BranchMoveDirection::Backward => { - format!("Move backward branch {branch_name} from {old} to {new}") - } - BranchMoveDirection::Sideways => { - format!("Move sideways branch {branch_name} from {old} to {new}") - } - }; - writeln!(ui.status(), " {msg}")?; - } - (Some(old_target), None) => { - writeln!( - ui.status(), - " Delete branch {branch_name} from {}", - short_commit_hash(old_target) - )?; - } - (None, Some(new_target)) => { - writeln!( - ui.status(), - " Add branch {branch_name} to {}", - short_commit_hash(new_target) - )?; - } - (None, None) => { - panic!("Not pushing any change to branch {branch_name}"); - } - } - } - - if args.dry_run { - writeln!(ui.status(), "Dry-run requested, not pushing.")?; - return Ok(()); - } - - let targets = GitBranchPushTargets { branch_updates }; - let mut writer = GitSidebandProgressMessageWriter::new(ui); - let mut sideband_progress_callback = |progress_message: &[u8]| { - _ = writer.write(ui, progress_message); - }; - with_remote_git_callbacks(ui, Some(&mut sideband_progress_callback), |cb| { - git::push_branches(tx.mut_repo(), &git_repo, &remote, &targets, cb) - }) - .map_err(|err| match err { - GitPushError::InternalGitError(err) => map_git_error(err), - GitPushError::RefInUnexpectedLocation(refs) => user_error_with_hint( - format!( - "Refusing to push a branch that unexpectedly moved on the remote. Affected refs: \ - {}", - refs.join(", ") - ), - "Try fetching from the remote, then make the branch point to where you want it to be, \ - and push again.", - ), - _ => user_error(err), - })?; - writer.flush(ui)?; - tx.finish(ui, tx_description)?; - Ok(()) -} - -fn get_default_push_remote( - ui: &Ui, - settings: &UserSettings, - git_repo: &git2::Repository, -) -> Result { - if let Some(remote) = settings.config().get_string("git.push").optional()? { - Ok(remote) - } else if let Some(remote) = get_single_remote(git_repo)? { - // similar to get_default_fetch_remotes - if remote != DEFAULT_REMOTE { - writeln!( - ui.hint_default(), - "Pushing to the only existing remote: {remote}" - )?; - } - Ok(remote) - } else { - Ok(DEFAULT_REMOTE.to_owned()) - } -} - -#[derive(Clone, Debug)] -struct RejectedBranchUpdateReason { - message: String, - hint: Option, -} - -impl RejectedBranchUpdateReason { - fn print(&self, ui: &Ui) -> io::Result<()> { - writeln!(ui.warning_default(), "{}", self.message)?; - if let Some(hint) = &self.hint { - writeln!(ui.hint_default(), "{hint}")?; - } - Ok(()) - } -} - -impl From for CommandError { - fn from(reason: RejectedBranchUpdateReason) -> Self { - let RejectedBranchUpdateReason { message, hint } = reason; - let mut cmd_err = user_error(message); - cmd_err.extend_hints(hint); - cmd_err - } -} - -fn classify_branch_update( - branch_name: &str, - remote_name: &str, - targets: LocalAndRemoteRef, -) -> Result, RejectedBranchUpdateReason> { - let push_action = classify_branch_push_action(targets); - match push_action { - BranchPushAction::AlreadyMatches => Ok(None), - BranchPushAction::LocalConflicted => Err(RejectedBranchUpdateReason { - message: format!("Branch {branch_name} is conflicted"), - hint: Some( - "Run `jj branch list` to inspect, and use `jj branch set` to fix it up.".to_owned(), - ), - }), - BranchPushAction::RemoteConflicted => Err(RejectedBranchUpdateReason { - message: format!("Branch {branch_name}@{remote_name} is conflicted"), - hint: Some("Run `jj git fetch` to update the conflicted remote branch.".to_owned()), - }), - BranchPushAction::RemoteUntracked => Err(RejectedBranchUpdateReason { - message: format!("Non-tracking remote branch {branch_name}@{remote_name} exists"), - hint: Some(format!( - "Run `jj branch track {branch_name}@{remote_name}` to import the remote branch." - )), - }), - BranchPushAction::Update(update) => Ok(Some(update)), - } -} - -/// Creates or moves branches based on the change IDs. -fn update_change_branches( - ui: &Ui, - tx: &mut WorkspaceCommandTransaction, - changes: &[RevisionArg], - branch_prefix: &str, -) -> Result, CommandError> { - let mut branch_names = Vec::new(); - for change_arg in changes { - let workspace_command = tx.base_workspace_helper(); - let commit = workspace_command.resolve_single_rev(change_arg)?; - let mut branch_name = format!("{branch_prefix}{}", commit.change_id().hex()); - let view = tx.base_repo().view(); - if view.get_local_branch(&branch_name).is_absent() { - // A local branch with the full change ID doesn't exist already, so use the - // short ID if it's not ambiguous (which it shouldn't be most of the time). - let short_change_id = short_change_hash(commit.change_id()); - if workspace_command - .resolve_single_rev(&RevisionArg::from(short_change_id.clone())) - .is_ok() - { - // Short change ID is not ambiguous, so update the branch name to use it. - branch_name = format!("{branch_prefix}{short_change_id}"); - }; - } - if view.get_local_branch(&branch_name).is_absent() { - writeln!( - ui.status(), - "Creating branch {branch_name} for revision {change_arg}", - )?; - } - tx.mut_repo() - .set_local_branch_target(&branch_name, RefTarget::normal(commit.id().clone())); - branch_names.push(branch_name); - } - Ok(branch_names) -} - -fn find_branches_to_push<'a>( - view: &'a View, - branch_patterns: &[StringPattern], - remote_name: &str, -) -> Result)>, CommandError> { - let mut matching_branches = vec![]; - let mut unmatched_patterns = vec![]; - for pattern in branch_patterns { - let mut matches = view - .local_remote_branches_matching(pattern, remote_name) - .filter(|(_, targets)| { - // If the remote exists but is not tracking, the absent local shouldn't - // be considered a deleted branch. - targets.local_target.is_present() || targets.remote_ref.is_tracking() - }) - .peekable(); - if matches.peek().is_none() { - unmatched_patterns.push(pattern); - } - matching_branches.extend(matches); - } - match &unmatched_patterns[..] { - [] => Ok(matching_branches), - [pattern] if pattern.is_exact() => Err(user_error(format!("No such branch: {pattern}"))), - patterns => Err(user_error(format!( - "No matching branches for patterns: {}", - patterns.iter().join(", ") - ))), - } -} - -fn find_branches_targeted_by_revisions<'a>( - ui: &Ui, - workspace_command: &'a WorkspaceCommandHelper, - remote_name: &str, - revisions: &[RevisionArg], - use_default_revset: bool, -) -> Result)>, CommandError> { - let mut revision_commit_ids = HashSet::new(); - if use_default_revset { - let Some(wc_commit_id) = workspace_command.get_wc_commit_id().cloned() else { - return Err(user_error("Nothing checked out in this workspace")); - }; - let current_branches_expression = RevsetExpression::remote_branches( - StringPattern::everything(), - StringPattern::Exact(remote_name.to_owned()), - ) - .range(&RevsetExpression::commit(wc_commit_id)) - .intersection(&RevsetExpression::branches(StringPattern::everything())); - let current_branches_revset = - current_branches_expression.evaluate_programmatic(workspace_command.repo().as_ref())?; - revision_commit_ids.extend(current_branches_revset.iter()); - if revision_commit_ids.is_empty() { - writeln!( - ui.warning_default(), - "No branches found in the default push revset: \ - remote_branches(remote={remote_name})..@" - )?; - } - } - for rev_arg in revisions { - let mut expression = workspace_command.parse_revset(rev_arg)?; - expression.intersect_with(&RevsetExpression::branches(StringPattern::everything())); - let mut commit_ids = expression.evaluate_to_commit_ids()?.peekable(); - if commit_ids.peek().is_none() { - writeln!( - ui.warning_default(), - "No branches point to the specified revisions: {rev_arg}" - )?; - } - revision_commit_ids.extend(commit_ids); - } - let branches_targeted = workspace_command - .repo() - .view() - .local_remote_branches(remote_name) - .filter(|(_, targets)| { - let mut local_ids = targets.local_target.added_ids(); - local_ids.any(|id| revision_commit_ids.contains(id)) - }) - .collect_vec(); - Ok(branches_targeted) -} - -fn cmd_git_import( - ui: &mut Ui, - command: &CommandHelper, - _args: &GitImportArgs, -) -> Result<(), CommandError> { - let mut workspace_command = command.workspace_helper(ui)?; - let mut tx = workspace_command.start_transaction(); - // In non-colocated repo, HEAD@git will never be moved internally by jj. - // That's why cmd_git_export() doesn't export the HEAD ref. - git::import_head(tx.mut_repo())?; - let stats = git::import_refs(tx.mut_repo(), &command.settings().git_settings())?; - print_git_import_stats(ui, tx.repo(), &stats, true)?; - tx.finish(ui, "import git refs")?; - Ok(()) -} - -fn cmd_git_export( - ui: &mut Ui, - command: &CommandHelper, - _args: &GitExportArgs, -) -> Result<(), CommandError> { - let mut workspace_command = command.workspace_helper(ui)?; - let mut tx = workspace_command.start_transaction(); - let failed_branches = git::export_refs(tx.mut_repo())?; - tx.finish(ui, "export git refs")?; - print_failed_git_export(ui, &failed_branches)?; - Ok(()) -} - -fn cmd_git_submodule_print_gitmodules( - ui: &mut Ui, - command: &CommandHelper, - args: &GitSubmodulePrintGitmodulesArgs, -) -> Result<(), CommandError> { - let workspace_command = command.workspace_helper(ui)?; - let repo = workspace_command.repo(); - let commit = workspace_command.resolve_single_rev(&args.revisions)?; - let tree = commit.tree()?; - let gitmodules_path = RepoPath::from_internal_string(".gitmodules"); - let mut gitmodules_file = match tree.path_value(gitmodules_path)?.into_resolved() { - Ok(None) => { - writeln!(ui.status(), "No submodules!")?; - return Ok(()); - } - Ok(Some(TreeValue::File { id, .. })) => repo.store().read_file(gitmodules_path, &id)?, - _ => { - return Err(user_error(".gitmodules is not a file.")); - } - }; - - let submodules = parse_gitmodules(&mut gitmodules_file)?; - for (name, submodule) in submodules { - writeln!( - ui.stdout(), - "name:{}\nurl:{}\npath:{}\n\n", - name, - submodule.url, - submodule.path - )?; - } - Ok(()) -} - -pub fn cmd_git( - ui: &mut Ui, - command: &CommandHelper, - subcommand: &GitCommand, -) -> Result<(), CommandError> { - match subcommand { - GitCommand::Init(args) => cmd_git_init(ui, command, args), - GitCommand::Fetch(args) => cmd_git_fetch(ui, command, args), - GitCommand::Clone(args) => cmd_git_clone(ui, command, args), - GitCommand::Remote(GitRemoteCommand::Add(args)) => cmd_git_remote_add(ui, command, args), - GitCommand::Remote(GitRemoteCommand::Remove(args)) => { - cmd_git_remote_remove(ui, command, args) - } - GitCommand::Remote(GitRemoteCommand::Rename(args)) => { - cmd_git_remote_rename(ui, command, args) - } - GitCommand::Remote(GitRemoteCommand::List(args)) => cmd_git_remote_list(ui, command, args), - GitCommand::Push(args) => cmd_git_push(ui, command, args), - GitCommand::Import(args) => cmd_git_import(ui, command, args), - GitCommand::Export(args) => cmd_git_export(ui, command, args), - GitCommand::Submodule(GitSubmoduleCommand::PrintGitmodules(args)) => { - cmd_git_submodule_print_gitmodules(ui, command, args) - } - } -} diff --git a/cli/src/commands/git/clone.rs b/cli/src/commands/git/clone.rs new file mode 100644 index 000000000..e8c07de5f --- /dev/null +++ b/cli/src/commands/git/clone.rs @@ -0,0 +1,211 @@ +// 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::Write; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use jj_lib::git::{self, GitFetchError, GitFetchStats}; +use jj_lib::repo::Repo; +use jj_lib::str_util::StringPattern; +use jj_lib::workspace::Workspace; + +use crate::cli_util::{CommandHelper, WorkspaceCommandHelper}; +use crate::command_error::{user_error, user_error_with_message, CommandError}; +use crate::commands::git::{map_git_error, maybe_add_gitignore}; +use crate::git_util::{get_git_repo, print_git_import_stats, 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 CloneArgs { + /// URL or path of the Git repo to clone + #[arg(value_hint = clap::ValueHint::DirPath)] + source: String, + /// The directory to write the Jujutsu repo to + #[arg(value_hint = clap::ValueHint::DirPath)] + destination: Option, + /// Whether or not to colocate the Jujutsu repo with the git repo + #[arg(long)] + colocate: bool, +} + +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: &CloneArgs, +) -> Result<(), CommandError> { + let remote_name = "origin"; + 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 = match fs::create_dir(&wc_path) { + Ok(()) => false, + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => true, + Err(err) => { + return Err(user_error_with_message( + format!("Failed to create {wc_path_str}"), + err, + )); + } + }; + if wc_path_existed && !is_empty_dir(&wc_path) { + return Err(user_error( + "Destination path exists and is not an empty directory", + )); + } + + // 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, + 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 { + let default_branch_remote_ref = workspace_command + .repo() + .view() + .get_remote_branch(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 branch as Git would do. + checkout_tx + .mut_repo() + .track_remote_branch(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, + 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_loaded_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.mut_repo(), + &git_repo, + remote_name, + &[StringPattern::everything()], + cb, + &command.settings().git_settings(), + ) + }) + .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)) +} diff --git a/cli/src/commands/git/export.rs b/cli/src/commands/git/export.rs new file mode 100644 index 000000000..c65bb59ee --- /dev/null +++ b/cli/src/commands/git/export.rs @@ -0,0 +1,37 @@ +// 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 jj_lib::git; + +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::git_util::print_failed_git_export; +use crate::ui::Ui; + +/// Update the underlying Git repo with changes made in the repo +#[derive(clap::Args, Clone, Debug)] +pub struct ExportArgs {} + +pub fn cmd_git_export( + ui: &mut Ui, + command: &CommandHelper, + _args: &ExportArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let mut tx = workspace_command.start_transaction(); + let failed_branches = git::export_refs(tx.mut_repo())?; + tx.finish(ui, "export git refs")?; + print_failed_git_export(ui, &failed_branches)?; + Ok(()) +} diff --git a/cli/src/commands/git/fetch.rs b/cli/src/commands/git/fetch.rs new file mode 100644 index 000000000..40f1fcdb2 --- /dev/null +++ b/cli/src/commands/git/fetch.rs @@ -0,0 +1,135 @@ +// 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 itertools::Itertools; +use jj_lib::git::{self, GitFetchError}; +use jj_lib::repo::Repo; +use jj_lib::settings::{ConfigResultExt as _, UserSettings}; +use jj_lib::str_util::StringPattern; + +use crate::cli_util::CommandHelper; +use crate::command_error::{user_error, user_error_with_hint, CommandError}; +use crate::commands::git::{get_single_remote, map_git_error}; +use crate::git_util::{get_git_repo, print_git_import_stats, with_remote_git_callbacks}; +use crate::ui::Ui; + +/// Fetch from a Git remote +/// +/// If a working-copy commit gets abandoned, it will be given a new, empty +/// commit. This is true in general; it is not specific to this command. +#[derive(clap::Args, Clone, Debug)] +pub struct FetchArgs { + /// Fetch only some of the branches + /// + /// By default, the specified name matches exactly. Use `glob:` prefix to + /// expand `*` as a glob. The other wildcard characters aren't supported. + #[arg(long, short, default_value = "glob:*", value_parser = StringPattern::parse)] + branch: Vec, + /// The remote to fetch from (only named remotes are supported, can be + /// repeated) + #[arg(long = "remote", value_name = "remote")] + remotes: Vec, + /// Fetch from all remotes + #[arg(long, conflicts_with = "remotes")] + all_remotes: bool, +} + +#[tracing::instrument(skip(ui, command))] +pub fn cmd_git_fetch( + ui: &mut Ui, + command: &CommandHelper, + args: &FetchArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let git_repo = get_git_repo(workspace_command.repo().store())?; + let remotes = if args.all_remotes { + get_all_remotes(&git_repo)? + } else if args.remotes.is_empty() { + get_default_fetch_remotes(ui, command.settings(), &git_repo)? + } else { + args.remotes.clone() + }; + let mut tx = workspace_command.start_transaction(); + for remote in &remotes { + let stats = with_remote_git_callbacks(ui, None, |cb| { + git::fetch( + tx.mut_repo(), + &git_repo, + remote, + &args.branch, + cb, + &command.settings().git_settings(), + ) + }) + .map_err(|err| match err { + GitFetchError::InvalidBranchPattern => { + if args + .branch + .iter() + .any(|pattern| pattern.as_exact().map_or(false, |s| s.contains('*'))) + { + user_error_with_hint( + err, + "Prefix the pattern with `glob:` to expand `*` as a glob", + ) + } else { + user_error(err) + } + } + GitFetchError::GitImportError(err) => err.into(), + GitFetchError::InternalGitError(err) => map_git_error(err), + _ => user_error(err), + })?; + print_git_import_stats(ui, tx.repo(), &stats.import_stats, true)?; + } + tx.finish( + ui, + format!("fetch from git remote(s) {}", remotes.iter().join(",")), + )?; + Ok(()) +} + +const DEFAULT_REMOTE: &str = "origin"; + +fn get_default_fetch_remotes( + ui: &Ui, + settings: &UserSettings, + git_repo: &git2::Repository, +) -> Result, CommandError> { + const KEY: &str = "git.fetch"; + if let Ok(remotes) = settings.config().get(KEY) { + Ok(remotes) + } else if let Some(remote) = settings.config().get_string(KEY).optional()? { + Ok(vec![remote]) + } else if let Some(remote) = get_single_remote(git_repo)? { + // if nothing was explicitly configured, try to guess + if remote != DEFAULT_REMOTE { + writeln!( + ui.hint_default(), + "Fetching from the only existing remote: {remote}" + )?; + } + Ok(vec![remote]) + } else { + Ok(vec![DEFAULT_REMOTE.to_owned()]) + } +} + +fn get_all_remotes(git_repo: &git2::Repository) -> Result, CommandError> { + let git_remotes = git_repo.remotes()?; + Ok(git_remotes + .iter() + .filter_map(|x| x.map(ToOwned::to_owned)) + .collect()) +} diff --git a/cli/src/commands/git/import.rs b/cli/src/commands/git/import.rs new file mode 100644 index 000000000..d296d473a --- /dev/null +++ b/cli/src/commands/git/import.rs @@ -0,0 +1,43 @@ +// 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 jj_lib::git; + +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::git_util::print_git_import_stats; +use crate::ui::Ui; + +/// Update repo with changes made in the underlying Git repo +/// +/// If a working-copy commit gets abandoned, it will be given a new, empty +/// commit. This is true in general; it is not specific to this command. +#[derive(clap::Args, Clone, Debug)] +pub struct ImportArgs {} + +pub fn cmd_git_import( + ui: &mut Ui, + command: &CommandHelper, + _args: &ImportArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let mut tx = workspace_command.start_transaction(); + // In non-colocated repo, HEAD@git will never be moved internally by jj. + // That's why cmd_git_export() doesn't export the HEAD ref. + git::import_head(tx.mut_repo())?; + let stats = git::import_refs(tx.mut_repo(), &command.settings().git_settings())?; + print_git_import_stats(ui, tx.repo(), &stats, true)?; + tx.finish(ui, "import git refs")?; + Ok(()) +} diff --git a/cli/src/commands/git/init.rs b/cli/src/commands/git/init.rs new file mode 100644 index 000000000..8d5b83c15 --- /dev/null +++ b/cli/src/commands/git/init.rs @@ -0,0 +1,212 @@ +// 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::Write; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use jj_lib::repo::{ReadonlyRepo, Repo}; +use jj_lib::workspace::Workspace; +use jj_lib::{file_util, git}; + +use crate::cli_util::{print_trackable_remote_branches, start_repo_transaction, CommandHelper}; +use crate::command_error::{user_error_with_hint, user_error_with_message, CommandError}; +use crate::commands::git::maybe_add_gitignore; +use crate::git_util::{ + is_colocated_git_workspace, print_failed_git_export, print_git_import_stats, +}; +use crate::ui::Ui; + +/// Create a new Git backed repo. +#[derive(clap::Args, Clone, Debug)] +pub struct InitArgs { + /// 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, + + /// 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. + /// + /// This is done by placing the backing git repo into a `.git` directory in + /// the root of the `jj` repo along with the `.jj` directory. If the `.git` + /// directory already exists, all the existing commits will be imported. + /// + /// This option is mutually exclusive with `--git-repo`. + #[arg(long, conflicts_with = "git_repo")] + colocate: bool, + + /// 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 co-located repo. + /// + /// This option is mutually exclusive with `--colocate`. + #[arg(long, conflicts_with = "colocate", value_hint = clap::ValueHint::DirPath)] + git_repo: Option, +} + +pub fn cmd_git_init( + ui: &mut Ui, + command: &CommandHelper, + args: &InitArgs, +) -> Result<(), CommandError> { + let cwd = command.cwd(); + let wc_path = cwd.join(&args.destination); + let wc_path = file_util::create_or_reuse_dir(&wc_path) + .and_then(|_| wc_path.canonicalize()) + .map_err(|e| user_error_with_message("Failed to create workspace", e))?; + + do_init( + ui, + command, + &wc_path, + args.colocate, + args.git_repo.as_deref(), + )?; + + let relative_wc_path = file_util::relative_path(cwd, &wc_path); + writeln!( + ui.status(), + r#"Initialized repo in "{}""#, + relative_wc_path.display() + )?; + + Ok(()) +} + +pub fn do_init( + ui: &mut Ui, + command: &CommandHelper, + workspace_root: &Path, + colocate: bool, + 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 colocate { + if colocated_git_repo_path.exists() { + GitInitMode::External(colocated_git_repo_path) + } else { + GitInitMode::Colocate + } + } else 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 colocated_git_repo_path.exists() { + return Err(user_error_with_hint( + "Did not create a jj repo because there is an existing Git repo in this directory.", + "To create a repo backed by the existing Git repo, run `jj git init --colocate` \ + instead.", + )); + } + GitInitMode::Internal + }; + + match &init_mode { + GitInitMode::Colocate => { + let (workspace, repo) = + Workspace::init_colocated_git(command.settings(), workspace_root)?; + let workspace_command = command.for_loaded_repo(ui, workspace, repo)?; + maybe_add_gitignore(&workspace_command)?; + } + GitInitMode::External(git_repo_path) => { + let (workspace, repo) = + Workspace::init_external_git(command.settings(), workspace_root, git_repo_path)?; + // Import refs first so all the reachable commits are indexed in + // chronological order. + let colocated = is_colocated_git_workspace(&workspace, &repo); + let repo = init_git_refs(ui, command, repo, colocated)?; + let mut workspace_command = command.for_loaded_repo(ui, workspace, repo)?; + maybe_add_gitignore(&workspace_command)?; + workspace_command.maybe_snapshot(ui)?; + if !workspace_command.working_copy_shared_with_git() { + let mut tx = workspace_command.start_transaction(); + jj_lib::git::import_head(tx.mut_repo())?; + if let Some(git_head_id) = tx.mut_repo().view().git_head().as_normal().cloned() { + let git_head_commit = tx.mut_repo().store().get_commit(&git_head_id)?; + tx.check_out(&git_head_commit)?; + } + if tx.mut_repo().has_changes() { + tx.finish(ui, "import git head")?; + } + } + print_trackable_remote_branches(ui, workspace_command.repo().view())?; + } + GitInitMode::Internal => { + Workspace::init_internal_git(command.settings(), workspace_root)?; + } + } + 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. +fn init_git_refs( + ui: &mut Ui, + command: &CommandHelper, + repo: Arc, + colocated: bool, +) -> Result, CommandError> { + let mut tx = start_repo_transaction(&repo, command.settings(), command.string_args()); + // There should be no old refs to abandon, but enforce it. + let mut git_settings = command.settings().git_settings(); + git_settings.abandon_unreachable_commits = false; + let stats = git::import_some_refs( + tx.mut_repo(), + &git_settings, + // Initial import shouldn't fail because of reserved remote name. + |ref_name| !git::is_reserved_git_remote_ref(ref_name), + )?; + if !tx.mut_repo().has_changes() { + return Ok(repo); + } + print_git_import_stats(ui, tx.repo(), &stats, false)?; + if colocated { + // If git.auto-local-branch = true, local branches could be created for + // the imported remote branches. + let failed_branches = git::export_refs(tx.mut_repo())?; + print_failed_git_export(ui, &failed_branches)?; + } + let repo = tx.commit("import git refs"); + writeln!( + ui.status(), + "Done importing changes from the underlying Git repo." + )?; + Ok(repo) +} diff --git a/cli/src/commands/git/mod.rs b/cli/src/commands/git/mod.rs new file mode 100644 index 000000000..4df07d0bb --- /dev/null +++ b/cli/src/commands/git/mod.rs @@ -0,0 +1,114 @@ +// 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. + +pub mod clone; +pub mod export; +pub mod fetch; +pub mod import; +pub mod init; +pub mod push; +pub mod remote; +pub mod submodule; + +use clap::Subcommand; + +use self::clone::{cmd_git_clone, CloneArgs}; +use self::export::{cmd_git_export, ExportArgs}; +use self::fetch::{cmd_git_fetch, FetchArgs}; +use self::import::{cmd_git_import, ImportArgs}; +use self::init::{cmd_git_init, InitArgs}; +use self::push::{cmd_git_push, PushArgs}; +use self::remote::{cmd_git_remote, RemoteCommand}; +use self::submodule::{cmd_git_submodule, SubmoduleCommand}; +use crate::cli_util::{CommandHelper, WorkspaceCommandHelper}; +use crate::command_error::{ + user_error, user_error_with_hint, user_error_with_message, CommandError, +}; +use crate::ui::Ui; + +/// Commands for working with Git remotes and the underlying Git repo +/// +/// For a comparison with Git, including a table of commands, see +/// https://github.com/martinvonz/jj/blob/main/docs/git-comparison.md. +#[derive(Subcommand, Clone, Debug)] +pub enum GitCommand { + #[command(subcommand)] + Remote(RemoteCommand), + Init(InitArgs), + Fetch(FetchArgs), + Clone(CloneArgs), + Push(PushArgs), + Import(ImportArgs), + Export(ExportArgs), + #[command(subcommand, hide = true)] + Submodule(SubmoduleCommand), +} + +fn map_git_error(err: git2::Error) -> CommandError { + if err.class() == git2::ErrorClass::Ssh { + let hint = + if err.code() == git2::ErrorCode::Certificate && std::env::var_os("HOME").is_none() { + "The HOME environment variable is not set, and might be required for Git to \ + successfully load certificates. Try setting it to the path of a directory that \ + contains a `.ssh` directory." + } else { + "Jujutsu uses libssh2, which doesn't respect ~/.ssh/config. Does `ssh -F \ + /dev/null` to the host work?" + }; + + user_error_with_hint(err, hint) + } else { + user_error(err.to_string()) + } +} + +pub fn maybe_add_gitignore(workspace_command: &WorkspaceCommandHelper) -> Result<(), CommandError> { + if workspace_command.working_copy_shared_with_git() { + std::fs::write( + workspace_command + .workspace_root() + .join(".jj") + .join(".gitignore"), + "/*\n", + ) + .map_err(|e| user_error_with_message("Failed to write .jj/.gitignore file", e)) + } else { + Ok(()) + } +} + +fn get_single_remote(git_repo: &git2::Repository) -> Result, CommandError> { + let git_remotes = git_repo.remotes()?; + Ok(match git_remotes.len() { + 1 => git_remotes.get(0).map(ToOwned::to_owned), + _ => None, + }) +} + +pub fn cmd_git( + ui: &mut Ui, + command: &CommandHelper, + subcommand: &GitCommand, +) -> Result<(), CommandError> { + match subcommand { + GitCommand::Init(args) => cmd_git_init(ui, command, args), + GitCommand::Fetch(args) => cmd_git_fetch(ui, command, args), + GitCommand::Clone(args) => cmd_git_clone(ui, command, args), + GitCommand::Remote(args) => cmd_git_remote(ui, command, args), + GitCommand::Push(args) => cmd_git_push(ui, command, args), + GitCommand::Import(args) => cmd_git_import(ui, command, args), + GitCommand::Export(args) => cmd_git_export(ui, command, args), + GitCommand::Submodule(args) => cmd_git_submodule(ui, command, args), + } +} diff --git a/cli/src/commands/git/push.rs b/cli/src/commands/git/push.rs new file mode 100644 index 000000000..40596b65a --- /dev/null +++ b/cli/src/commands/git/push.rs @@ -0,0 +1,579 @@ +// 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::collections::{HashMap, HashSet}; +use std::io::Write; +use std::{fmt, io}; + +use clap::ArgGroup; +use itertools::Itertools; +use jj_lib::git::{self, GitBranchPushTargets, GitPushError}; +use jj_lib::object_id::ObjectId; +use jj_lib::op_store::RefTarget; +use jj_lib::refs::{ + classify_branch_push_action, BranchPushAction, BranchPushUpdate, LocalAndRemoteRef, +}; +use jj_lib::repo::Repo; +use jj_lib::revset::{self, RevsetExpression, RevsetIteratorExt as _}; +use jj_lib::settings::{ConfigResultExt as _, UserSettings}; +use jj_lib::str_util::StringPattern; +use jj_lib::view::View; + +use crate::cli_util::{ + short_change_hash, short_commit_hash, CommandHelper, RevisionArg, WorkspaceCommandHelper, + WorkspaceCommandTransaction, +}; +use crate::command_error::{user_error, user_error_with_hint, CommandError}; +use crate::commands::git::{get_single_remote, map_git_error}; +use crate::git_util::{get_git_repo, with_remote_git_callbacks, GitSidebandProgressMessageWriter}; +use crate::ui::Ui; + +/// Push to a Git remote +/// +/// By default, pushes any branches pointing to +/// `remote_branches(remote=)..@`. Use `--branch` to push specific +/// branches. Use `--all` to push all branches. Use `--change` to generate +/// branch names based on the change IDs of specific commits. +/// +/// Before the command actually moves, creates, or deletes a remote branch, it +/// makes several [safety checks]. If there is a problem, you may need to run +/// `jj git fetch --remote ` and/or resolve some [branch +/// conflicts]. +/// +/// [safety checks]: +/// https://martinvonz.github.io/jj/latest/branches/#pushing-branches-safety-checks +/// +/// [branch conflicts]: +/// https://martinvonz.github.io/jj/latest/branches/#conflicts + +#[derive(clap::Args, Clone, Debug)] +#[command(group(ArgGroup::new("specific").args(&["branch", "change", "revisions"]).multiple(true)))] +#[command(group(ArgGroup::new("what").args(&["all", "deleted", "tracked"]).conflicts_with("specific")))] +pub struct PushArgs { + /// The remote to push to (only named remotes are supported) + #[arg(long)] + remote: Option, + /// Push only this branch, or branches matching a pattern (can be repeated) + /// + /// By default, the specified name matches exactly. Use `glob:` prefix to + /// select branches by wildcard pattern. For details, see + /// https://martinvonz.github.io/jj/latest/revsets#string-patterns. + #[arg(long, short, value_parser = StringPattern::parse)] + branch: Vec, + /// Push all branches (including deleted branches) + #[arg(long)] + all: bool, + /// Push all tracked branches (including deleted branches) + /// + /// This usually means that the branch was already pushed to or fetched from + /// the relevant remote. For details, see + /// https://martinvonz.github.io/jj/latest/branches#remotes-and-tracked-branches + #[arg(long)] + tracked: bool, + /// Push all deleted branches + /// + /// Only tracked branches can be successfully deleted on the remote. A + /// warning will be printed if any untracked branches on the remote + /// correspond to missing local branches. + #[arg(long)] + deleted: bool, + /// Allow pushing commits with empty descriptions + #[arg(long)] + allow_empty_description: bool, + /// Push branches pointing to these commits (can be repeated) + #[arg(long, short)] + revisions: Vec, + /// Push this commit by creating a branch based on its change ID (can be + /// repeated) + #[arg(long, short)] + change: Vec, + /// Only display what will change on the remote + #[arg(long)] + dry_run: bool, +} + +fn make_branch_term(branch_names: &[impl fmt::Display]) -> String { + match branch_names { + [branch_name] => format!("branch {}", branch_name), + branch_names => format!("branches {}", branch_names.iter().join(", ")), + } +} + +const DEFAULT_REMOTE: &str = "origin"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BranchMoveDirection { + Forward, + Backward, + Sideways, +} + +pub fn cmd_git_push( + ui: &mut Ui, + command: &CommandHelper, + args: &PushArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let git_repo = get_git_repo(workspace_command.repo().store())?; + + let remote = if let Some(name) = &args.remote { + name.clone() + } else { + get_default_push_remote(ui, command.settings(), &git_repo)? + }; + + let repo = workspace_command.repo().clone(); + let mut tx = workspace_command.start_transaction(); + let tx_description; + let mut branch_updates = vec![]; + if args.all { + for (branch_name, targets) in repo.view().local_remote_branches(&remote) { + match classify_branch_update(branch_name, &remote, targets) { + Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), + Ok(None) => {} + Err(reason) => reason.print(ui)?, + } + } + tx_description = format!("push all branches to git remote {remote}"); + } else if args.tracked { + for (branch_name, targets) in repo.view().local_remote_branches(&remote) { + if !targets.remote_ref.is_tracking() { + continue; + } + match classify_branch_update(branch_name, &remote, targets) { + Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), + Ok(None) => {} + Err(reason) => reason.print(ui)?, + } + } + tx_description = format!("push all tracked branches to git remote {remote}"); + } else if args.deleted { + for (branch_name, targets) in repo.view().local_remote_branches(&remote) { + if targets.local_target.is_present() { + continue; + } + match classify_branch_update(branch_name, &remote, targets) { + Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), + Ok(None) => {} + Err(reason) => reason.print(ui)?, + } + } + tx_description = format!("push all deleted branches to git remote {remote}"); + } else { + let mut seen_branches: HashSet<&str> = HashSet::new(); + + // Process --change branches first because matching branches can be moved. + let change_branch_names = update_change_branches( + ui, + &mut tx, + &args.change, + &command.settings().push_branch_prefix(), + )?; + let change_branches = change_branch_names.iter().map(|branch_name| { + let targets = LocalAndRemoteRef { + local_target: tx.repo().view().get_local_branch(branch_name), + remote_ref: tx.repo().view().get_remote_branch(branch_name, &remote), + }; + (branch_name.as_ref(), targets) + }); + let branches_by_name = find_branches_to_push(repo.view(), &args.branch, &remote)?; + for (branch_name, targets) in change_branches.chain(branches_by_name.iter().copied()) { + if !seen_branches.insert(branch_name) { + continue; + } + match classify_branch_update(branch_name, &remote, targets) { + Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), + Ok(None) => writeln!( + ui.status(), + "Branch {branch_name}@{remote} already matches {branch_name}", + )?, + Err(reason) => return Err(reason.into()), + } + } + + let use_default_revset = + args.branch.is_empty() && args.change.is_empty() && args.revisions.is_empty(); + let branches_targeted = find_branches_targeted_by_revisions( + ui, + tx.base_workspace_helper(), + &remote, + &args.revisions, + use_default_revset, + )?; + for &(branch_name, targets) in &branches_targeted { + if !seen_branches.insert(branch_name) { + continue; + } + match classify_branch_update(branch_name, &remote, targets) { + Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), + Ok(None) => {} + Err(reason) => reason.print(ui)?, + } + } + + tx_description = format!( + "push {} to git remote {}", + make_branch_term( + &branch_updates + .iter() + .map(|(branch, _)| branch.as_str()) + .collect_vec() + ), + &remote + ); + } + if branch_updates.is_empty() { + writeln!(ui.status(), "Nothing changed.")?; + return Ok(()); + } + + let mut branch_push_direction = HashMap::new(); + for (branch_name, update) in &branch_updates { + let BranchPushUpdate { + old_target: Some(old_target), + new_target: Some(new_target), + } = update + else { + continue; + }; + assert_ne!(old_target, new_target); + branch_push_direction.insert( + branch_name.to_string(), + if repo.index().is_ancestor(old_target, new_target) { + BranchMoveDirection::Forward + } else if repo.index().is_ancestor(new_target, old_target) { + BranchMoveDirection::Backward + } else { + BranchMoveDirection::Sideways + }, + ); + } + + // Check if there are conflicts in any commits we're about to push that haven't + // already been pushed. + let new_heads = branch_updates + .iter() + .filter_map(|(_, update)| update.new_target.clone()) + .collect_vec(); + let mut old_heads = repo + .view() + .remote_branches(&remote) + .flat_map(|(_, old_head)| old_head.target.added_ids()) + .cloned() + .collect_vec(); + if old_heads.is_empty() { + old_heads.push(repo.store().root_commit_id().clone()); + } + for commit in revset::walk_revs(repo.as_ref(), &new_heads, &old_heads)? + .iter() + .commits(repo.store()) + { + let commit = commit?; + let mut reasons = vec![]; + if commit.description().is_empty() && !args.allow_empty_description { + reasons.push("it has no description"); + } + if commit.author().name.is_empty() + || commit.author().name == UserSettings::USER_NAME_PLACEHOLDER + || commit.author().email.is_empty() + || commit.author().email == UserSettings::USER_EMAIL_PLACEHOLDER + || commit.committer().name.is_empty() + || commit.committer().name == UserSettings::USER_NAME_PLACEHOLDER + || commit.committer().email.is_empty() + || commit.committer().email == UserSettings::USER_EMAIL_PLACEHOLDER + { + reasons.push("it has no author and/or committer set"); + } + if commit.has_conflict()? { + reasons.push("it has conflicts"); + } + if !reasons.is_empty() { + return Err(user_error(format!( + "Won't push commit {} since {}", + short_commit_hash(commit.id()), + reasons.join(" and ") + ))); + } + } + + writeln!(ui.status(), "Branch changes to push to {}:", &remote)?; + for (branch_name, update) in &branch_updates { + match (&update.old_target, &update.new_target) { + (Some(old_target), Some(new_target)) => { + let old = short_commit_hash(old_target); + let new = short_commit_hash(new_target); + // TODO(ilyagr): Add color. Once there is color, "Move branch ... sideways" may + // read more naturally than "Move sideways branch ...". Without color, it's hard + // to see at a glance if one branch among many was moved sideways (say). + // TODO: People on Discord suggest "Move branch ... forward by n commits", + // possibly "Move branch ... sideways (X forward, Y back)". + let msg = match branch_push_direction.get(branch_name).unwrap() { + BranchMoveDirection::Forward => { + format!("Move forward branch {branch_name} from {old} to {new}") + } + BranchMoveDirection::Backward => { + format!("Move backward branch {branch_name} from {old} to {new}") + } + BranchMoveDirection::Sideways => { + format!("Move sideways branch {branch_name} from {old} to {new}") + } + }; + writeln!(ui.status(), " {msg}")?; + } + (Some(old_target), None) => { + writeln!( + ui.status(), + " Delete branch {branch_name} from {}", + short_commit_hash(old_target) + )?; + } + (None, Some(new_target)) => { + writeln!( + ui.status(), + " Add branch {branch_name} to {}", + short_commit_hash(new_target) + )?; + } + (None, None) => { + panic!("Not pushing any change to branch {branch_name}"); + } + } + } + + if args.dry_run { + writeln!(ui.status(), "Dry-run requested, not pushing.")?; + return Ok(()); + } + + let targets = GitBranchPushTargets { branch_updates }; + let mut writer = GitSidebandProgressMessageWriter::new(ui); + let mut sideband_progress_callback = |progress_message: &[u8]| { + _ = writer.write(ui, progress_message); + }; + with_remote_git_callbacks(ui, Some(&mut sideband_progress_callback), |cb| { + git::push_branches(tx.mut_repo(), &git_repo, &remote, &targets, cb) + }) + .map_err(|err| match err { + GitPushError::InternalGitError(err) => map_git_error(err), + GitPushError::RefInUnexpectedLocation(refs) => user_error_with_hint( + format!( + "Refusing to push a branch that unexpectedly moved on the remote. Affected refs: \ + {}", + refs.join(", ") + ), + "Try fetching from the remote, then make the branch point to where you want it to be, \ + and push again.", + ), + _ => user_error(err), + })?; + writer.flush(ui)?; + tx.finish(ui, tx_description)?; + Ok(()) +} + +fn get_default_push_remote( + ui: &Ui, + settings: &UserSettings, + git_repo: &git2::Repository, +) -> Result { + if let Some(remote) = settings.config().get_string("git.push").optional()? { + Ok(remote) + } else if let Some(remote) = get_single_remote(git_repo)? { + // similar to get_default_fetch_remotes + if remote != DEFAULT_REMOTE { + writeln!( + ui.hint_default(), + "Pushing to the only existing remote: {remote}" + )?; + } + Ok(remote) + } else { + Ok(DEFAULT_REMOTE.to_owned()) + } +} + +#[derive(Clone, Debug)] +struct RejectedBranchUpdateReason { + message: String, + hint: Option, +} + +impl RejectedBranchUpdateReason { + fn print(&self, ui: &Ui) -> io::Result<()> { + writeln!(ui.warning_default(), "{}", self.message)?; + if let Some(hint) = &self.hint { + writeln!(ui.hint_default(), "{hint}")?; + } + Ok(()) + } +} + +impl From for CommandError { + fn from(reason: RejectedBranchUpdateReason) -> Self { + let RejectedBranchUpdateReason { message, hint } = reason; + let mut cmd_err = user_error(message); + cmd_err.extend_hints(hint); + cmd_err + } +} + +fn classify_branch_update( + branch_name: &str, + remote_name: &str, + targets: LocalAndRemoteRef, +) -> Result, RejectedBranchUpdateReason> { + let push_action = classify_branch_push_action(targets); + match push_action { + BranchPushAction::AlreadyMatches => Ok(None), + BranchPushAction::LocalConflicted => Err(RejectedBranchUpdateReason { + message: format!("Branch {branch_name} is conflicted"), + hint: Some( + "Run `jj branch list` to inspect, and use `jj branch set` to fix it up.".to_owned(), + ), + }), + BranchPushAction::RemoteConflicted => Err(RejectedBranchUpdateReason { + message: format!("Branch {branch_name}@{remote_name} is conflicted"), + hint: Some("Run `jj git fetch` to update the conflicted remote branch.".to_owned()), + }), + BranchPushAction::RemoteUntracked => Err(RejectedBranchUpdateReason { + message: format!("Non-tracking remote branch {branch_name}@{remote_name} exists"), + hint: Some(format!( + "Run `jj branch track {branch_name}@{remote_name}` to import the remote branch." + )), + }), + BranchPushAction::Update(update) => Ok(Some(update)), + } +} + +/// Creates or moves branches based on the change IDs. +fn update_change_branches( + ui: &Ui, + tx: &mut WorkspaceCommandTransaction, + changes: &[RevisionArg], + branch_prefix: &str, +) -> Result, CommandError> { + let mut branch_names = Vec::new(); + for change_arg in changes { + let workspace_command = tx.base_workspace_helper(); + let commit = workspace_command.resolve_single_rev(change_arg)?; + let mut branch_name = format!("{branch_prefix}{}", commit.change_id().hex()); + let view = tx.base_repo().view(); + if view.get_local_branch(&branch_name).is_absent() { + // A local branch with the full change ID doesn't exist already, so use the + // short ID if it's not ambiguous (which it shouldn't be most of the time). + let short_change_id = short_change_hash(commit.change_id()); + if workspace_command + .resolve_single_rev(&RevisionArg::from(short_change_id.clone())) + .is_ok() + { + // Short change ID is not ambiguous, so update the branch name to use it. + branch_name = format!("{branch_prefix}{short_change_id}"); + }; + } + if view.get_local_branch(&branch_name).is_absent() { + writeln!( + ui.status(), + "Creating branch {branch_name} for revision {change_arg}", + )?; + } + tx.mut_repo() + .set_local_branch_target(&branch_name, RefTarget::normal(commit.id().clone())); + branch_names.push(branch_name); + } + Ok(branch_names) +} + +fn find_branches_to_push<'a>( + view: &'a View, + branch_patterns: &[StringPattern], + remote_name: &str, +) -> Result)>, CommandError> { + let mut matching_branches = vec![]; + let mut unmatched_patterns = vec![]; + for pattern in branch_patterns { + let mut matches = view + .local_remote_branches_matching(pattern, remote_name) + .filter(|(_, targets)| { + // If the remote exists but is not tracking, the absent local shouldn't + // be considered a deleted branch. + targets.local_target.is_present() || targets.remote_ref.is_tracking() + }) + .peekable(); + if matches.peek().is_none() { + unmatched_patterns.push(pattern); + } + matching_branches.extend(matches); + } + match &unmatched_patterns[..] { + [] => Ok(matching_branches), + [pattern] if pattern.is_exact() => Err(user_error(format!("No such branch: {pattern}"))), + patterns => Err(user_error(format!( + "No matching branches for patterns: {}", + patterns.iter().join(", ") + ))), + } +} + +fn find_branches_targeted_by_revisions<'a>( + ui: &Ui, + workspace_command: &'a WorkspaceCommandHelper, + remote_name: &str, + revisions: &[RevisionArg], + use_default_revset: bool, +) -> Result)>, CommandError> { + let mut revision_commit_ids = HashSet::new(); + if use_default_revset { + let Some(wc_commit_id) = workspace_command.get_wc_commit_id().cloned() else { + return Err(user_error("Nothing checked out in this workspace")); + }; + let current_branches_expression = RevsetExpression::remote_branches( + StringPattern::everything(), + StringPattern::Exact(remote_name.to_owned()), + ) + .range(&RevsetExpression::commit(wc_commit_id)) + .intersection(&RevsetExpression::branches(StringPattern::everything())); + let current_branches_revset = + current_branches_expression.evaluate_programmatic(workspace_command.repo().as_ref())?; + revision_commit_ids.extend(current_branches_revset.iter()); + if revision_commit_ids.is_empty() { + writeln!( + ui.warning_default(), + "No branches found in the default push revset: \ + remote_branches(remote={remote_name})..@" + )?; + } + } + for rev_arg in revisions { + let mut expression = workspace_command.parse_revset(rev_arg)?; + expression.intersect_with(&RevsetExpression::branches(StringPattern::everything())); + let mut commit_ids = expression.evaluate_to_commit_ids()?.peekable(); + if commit_ids.peek().is_none() { + writeln!( + ui.warning_default(), + "No branches point to the specified revisions: {rev_arg}" + )?; + } + revision_commit_ids.extend(commit_ids); + } + let branches_targeted = workspace_command + .repo() + .view() + .local_remote_branches(remote_name) + .filter(|(_, targets)| { + let mut local_ids = targets.local_target.added_ids(); + local_ids.any(|id| revision_commit_ids.contains(id)) + }) + .collect_vec(); + Ok(branches_targeted) +} diff --git a/cli/src/commands/git/remote/add.rs b/cli/src/commands/git/remote/add.rs new file mode 100644 index 000000000..b24fe75bf --- /dev/null +++ b/cli/src/commands/git/remote/add.rs @@ -0,0 +1,42 @@ +// 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 jj_lib::git; +use jj_lib::repo::Repo; + +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::git_util::get_git_repo; +use crate::ui::Ui; + +/// Add a Git remote +#[derive(clap::Args, Clone, Debug)] +pub struct AddArgs { + /// The remote's name + remote: String, + /// The remote's URL + url: String, +} + +pub fn cmd_remote_add( + ui: &mut Ui, + command: &CommandHelper, + args: &AddArgs, +) -> Result<(), CommandError> { + let workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo(); + let git_repo = get_git_repo(repo.store())?; + git::add_remote(&git_repo, &args.remote, &args.url)?; + Ok(()) +} diff --git a/cli/src/commands/git/remote/list.rs b/cli/src/commands/git/remote/list.rs new file mode 100644 index 000000000..8088f9299 --- /dev/null +++ b/cli/src/commands/git/remote/list.rs @@ -0,0 +1,46 @@ +// 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::Write; + +use jj_lib::repo::Repo; + +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::git_util::get_git_repo; +use crate::ui::Ui; + +/// List Git remotes +#[derive(clap::Args, Clone, Debug)] +pub struct ListArgs {} + +pub fn cmd_remote_list( + ui: &mut Ui, + command: &CommandHelper, + _args: &ListArgs, +) -> Result<(), CommandError> { + let workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo(); + let git_repo = get_git_repo(repo.store())?; + for remote_name in git_repo.remotes()?.iter().flatten() { + let remote = git_repo.find_remote(remote_name)?; + writeln!( + ui.stdout(), + "{} {}", + remote_name, + remote.url().unwrap_or("") + )?; + } + Ok(()) +} diff --git a/cli/src/commands/git/remote/mod.rs b/cli/src/commands/git/remote/mod.rs new file mode 100644 index 000000000..617673e91 --- /dev/null +++ b/cli/src/commands/git/remote/mod.rs @@ -0,0 +1,52 @@ +// 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. + +pub mod add; +pub mod list; +pub mod remove; +pub mod rename; + +use clap::Subcommand; + +use self::add::{cmd_remote_add, AddArgs}; +use self::list::{cmd_remote_list, ListArgs}; +use self::remove::{cmd_remote_remove, RemoveArgs}; +use self::rename::{cmd_remote_rename, RenameArgs}; +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::ui::Ui; + +/// Manage Git remotes +/// +/// The Git repo will be a bare git repo stored inside the `.jj/` directory. +#[derive(Subcommand, Clone, Debug)] +pub enum RemoteCommand { + Add(AddArgs), + Remove(RemoveArgs), + Rename(RenameArgs), + List(ListArgs), +} + +pub fn cmd_git_remote( + ui: &mut Ui, + command: &CommandHelper, + subcommand: &RemoteCommand, +) -> Result<(), CommandError> { + match subcommand { + RemoteCommand::Add(args) => cmd_remote_add(ui, command, args), + RemoteCommand::Remove(args) => cmd_remote_remove(ui, command, args), + RemoteCommand::Rename(args) => cmd_remote_rename(ui, command, args), + RemoteCommand::List(args) => cmd_remote_list(ui, command, args), + } +} diff --git a/cli/src/commands/git/remote/remove.rs b/cli/src/commands/git/remote/remove.rs new file mode 100644 index 000000000..3ef7fac1e --- /dev/null +++ b/cli/src/commands/git/remote/remove.rs @@ -0,0 +1,45 @@ +// 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 jj_lib::git; +use jj_lib::repo::Repo; + +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::git_util::get_git_repo; +use crate::ui::Ui; + +/// Remove a Git remote and forget its branches +#[derive(clap::Args, Clone, Debug)] +pub struct RemoveArgs { + /// The remote's name + remote: String, +} + +pub fn cmd_remote_remove( + ui: &mut Ui, + command: &CommandHelper, + args: &RemoveArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo(); + let git_repo = get_git_repo(repo.store())?; + let mut tx = workspace_command.start_transaction(); + git::remove_remote(tx.mut_repo(), &git_repo, &args.remote)?; + if tx.mut_repo().has_changes() { + tx.finish(ui, format!("remove git remote {}", &args.remote)) + } else { + Ok(()) // Do not print "Nothing changed." + } +} diff --git a/cli/src/commands/git/remote/rename.rs b/cli/src/commands/git/remote/rename.rs new file mode 100644 index 000000000..366a3cab3 --- /dev/null +++ b/cli/src/commands/git/remote/rename.rs @@ -0,0 +1,50 @@ +// 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 jj_lib::git; +use jj_lib::repo::Repo; + +use crate::cli_util::CommandHelper; +use crate::command_error::CommandError; +use crate::git_util::get_git_repo; +use crate::ui::Ui; + +/// Rename a Git remote +#[derive(clap::Args, Clone, Debug)] +pub struct RenameArgs { + /// The name of an existing remote + old: String, + /// The desired name for `old` + new: String, +} + +pub fn cmd_remote_rename( + ui: &mut Ui, + command: &CommandHelper, + args: &RenameArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo(); + let git_repo = get_git_repo(repo.store())?; + let mut tx = workspace_command.start_transaction(); + git::rename_remote(tx.mut_repo(), &git_repo, &args.old, &args.new)?; + if tx.mut_repo().has_changes() { + tx.finish( + ui, + format!("rename git remote {} to {}", &args.old, &args.new), + ) + } else { + Ok(()) // Do not print "Nothing changed." + } +} diff --git a/cli/src/commands/git/submodule.rs b/cli/src/commands/git/submodule.rs new file mode 100644 index 000000000..348370211 --- /dev/null +++ b/cli/src/commands/git/submodule.rs @@ -0,0 +1,89 @@ +// 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::Write; + +use clap::Subcommand; +use jj_lib::backend::TreeValue; +use jj_lib::git::parse_gitmodules; +use jj_lib::repo::Repo; +use jj_lib::repo_path::RepoPath; + +use crate::cli_util::{CommandHelper, RevisionArg}; +use crate::command_error::{user_error, CommandError}; +use crate::ui::Ui; + +/// FOR INTERNAL USE ONLY Interact with git submodules +#[derive(Subcommand, Clone, Debug)] +pub enum SubmoduleCommand { + /// Print the relevant contents from .gitmodules. For debugging purposes + /// only. + PrintGitmodules(PrintArgs), +} + +pub fn cmd_git_submodule( + ui: &mut Ui, + command: &CommandHelper, + subcommand: &SubmoduleCommand, +) -> Result<(), CommandError> { + match subcommand { + SubmoduleCommand::PrintGitmodules(args) => cmd_submodule_print(ui, command, args), + } +} + +// TODO: break everything below into a separate file as soon as there is more +// than one subcommand here. + +/// Print debugging info about Git submodules +#[derive(clap::Args, Clone, Debug)] +#[command(hide = true)] +pub struct PrintArgs { + /// Read .gitmodules from the given revision. + #[arg(long, short = 'r', default_value = "@")] + revisions: RevisionArg, +} + +fn cmd_submodule_print( + ui: &mut Ui, + command: &CommandHelper, + args: &PrintArgs, +) -> Result<(), CommandError> { + let workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo(); + let commit = workspace_command.resolve_single_rev(&args.revisions)?; + let tree = commit.tree()?; + let gitmodules_path = RepoPath::from_internal_string(".gitmodules"); + let mut gitmodules_file = match tree.path_value(gitmodules_path)?.into_resolved() { + Ok(None) => { + writeln!(ui.status(), "No submodules!")?; + return Ok(()); + } + Ok(Some(TreeValue::File { id, .. })) => repo.store().read_file(gitmodules_path, &id)?, + _ => { + return Err(user_error(".gitmodules is not a file.")); + } + }; + + let submodules = parse_gitmodules(&mut gitmodules_file)?; + for (name, submodule) in submodules { + writeln!( + ui.stdout(), + "name:{}\nurl:{}\npath:{}\n\n", + name, + submodule.url, + submodule.path + )?; + } + Ok(()) +} diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs index 0e6d952dc..1ec74ed47 100644 --- a/cli/src/commands/init.rs +++ b/cli/src/commands/init.rs @@ -60,12 +60,12 @@ pub(crate) fn cmd_init( // a colocated repo. let colocate = false; if args.git || args.git_repo.is_some() { - git::git_init(ui, command, &wc_path, colocate, args.git_repo.as_deref())?; + git::init::do_init(ui, command, &wc_path, colocate, args.git_repo.as_deref())?; writeln!( ui.warning_default(), "`--git` and `--git-repo` are deprecated. Use `jj git init` instead" - )?; + )? } else { if !command.settings().allow_native_backend() { return Err(user_error_with_hint(