just playing with tangled
at diffedit3 1313 lines 48 kB view raw
1// Copyright 2020-2023 The Jujutsu Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15use std::collections::HashSet; 16use std::io::Write; 17use std::path::{Path, PathBuf}; 18use std::sync::Arc; 19use std::{fmt, fs, io}; 20 21use clap::{ArgGroup, Subcommand}; 22use itertools::Itertools; 23use jj_lib::backend::TreeValue; 24use jj_lib::file_util; 25use jj_lib::git::{ 26 self, parse_gitmodules, GitBranchPushTargets, GitFetchError, GitFetchStats, GitPushError, 27}; 28use jj_lib::object_id::ObjectId; 29use jj_lib::op_store::RefTarget; 30use jj_lib::refs::{ 31 classify_branch_push_action, BranchPushAction, BranchPushUpdate, LocalAndRemoteRef, 32}; 33use jj_lib::repo::{ReadonlyRepo, Repo}; 34use jj_lib::repo_path::RepoPath; 35use jj_lib::revset::{self, RevsetExpression, RevsetIteratorExt as _}; 36use jj_lib::settings::{ConfigResultExt as _, UserSettings}; 37use jj_lib::str_util::StringPattern; 38use jj_lib::view::View; 39use jj_lib::workspace::Workspace; 40use maplit::hashset; 41 42use crate::cli_util::{ 43 print_trackable_remote_branches, short_change_hash, short_commit_hash, start_repo_transaction, 44 CommandHelper, RevisionArg, WorkspaceCommandHelper, WorkspaceCommandTransaction, 45}; 46use crate::command_error::{ 47 user_error, user_error_with_hint, user_error_with_message, CommandError, 48}; 49use crate::git_util::{ 50 get_git_repo, is_colocated_git_workspace, print_failed_git_export, print_git_import_stats, 51 with_remote_git_callbacks, GitSidebandProgressMessageWriter, 52}; 53use crate::ui::Ui; 54 55/// Commands for working with the underlying Git repo 56/// 57/// For a comparison with Git, including a table of commands, see 58/// https://github.com/martinvonz/jj/blob/main/docs/git-comparison.md. 59#[derive(Subcommand, Clone, Debug)] 60pub enum GitCommand { 61 #[command(subcommand)] 62 Remote(GitRemoteCommand), 63 Init(GitInitArgs), 64 Fetch(GitFetchArgs), 65 Clone(GitCloneArgs), 66 Push(GitPushArgs), 67 Import(GitImportArgs), 68 Export(GitExportArgs), 69 #[command(subcommand, hide = true)] 70 Submodule(GitSubmoduleCommand), 71} 72 73/// Manage Git remotes 74/// 75/// The Git repo will be a bare git repo stored inside the `.jj/` directory. 76#[derive(Subcommand, Clone, Debug)] 77pub enum GitRemoteCommand { 78 Add(GitRemoteAddArgs), 79 Remove(GitRemoteRemoveArgs), 80 Rename(GitRemoteRenameArgs), 81 List(GitRemoteListArgs), 82} 83 84/// Add a Git remote 85#[derive(clap::Args, Clone, Debug)] 86pub struct GitRemoteAddArgs { 87 /// The remote's name 88 remote: String, 89 /// The remote's URL 90 url: String, 91} 92 93/// Remove a Git remote and forget its branches 94#[derive(clap::Args, Clone, Debug)] 95pub struct GitRemoteRemoveArgs { 96 /// The remote's name 97 remote: String, 98} 99 100/// Rename a Git remote 101#[derive(clap::Args, Clone, Debug)] 102pub struct GitRemoteRenameArgs { 103 /// The name of an existing remote 104 old: String, 105 /// The desired name for `old` 106 new: String, 107} 108 109/// List Git remotes 110#[derive(clap::Args, Clone, Debug)] 111pub struct GitRemoteListArgs {} 112 113/// Create a new Git backed repo. 114#[derive(clap::Args, Clone, Debug)] 115pub struct GitInitArgs { 116 /// The destination directory where the `jj` repo will be created. 117 /// If the directory does not exist, it will be created. 118 /// If no directory is given, the current directory is used. 119 /// 120 /// By default the `git` repo is under `$destination/.jj` 121 #[arg(default_value = ".", value_hint = clap::ValueHint::DirPath)] 122 destination: String, 123 124 /// Specifies that the `jj` repo should also be a valid 125 /// `git` repo, allowing the use of both `jj` and `git` commands 126 /// in the same directory. 127 /// 128 /// This is done by placing the backing git repo into a `.git` directory in 129 /// the root of the `jj` repo along with the `.jj` directory. If the `.git` 130 /// directory already exists, all the existing commits will be imported. 131 /// 132 /// This option is mutually exclusive with `--git-repo`. 133 #[arg(long, conflicts_with = "git_repo")] 134 colocate: bool, 135 136 /// Specifies a path to an **existing** git repository to be 137 /// used as the backing git repo for the newly created `jj` repo. 138 /// 139 /// If the specified `--git-repo` path happens to be the same as 140 /// the `jj` repo path (both .jj and .git directories are in the 141 /// same working directory), then both `jj` and `git` commands 142 /// will work on the same repo. This is called a co-located repo. 143 /// 144 /// This option is mutually exclusive with `--colocate`. 145 #[arg(long, conflicts_with = "colocate", value_hint = clap::ValueHint::DirPath)] 146 git_repo: Option<String>, 147} 148 149/// Fetch from a Git remote 150/// 151/// If a working-copy commit gets abandoned, it will be given a new, empty 152/// commit. This is true in general; it is not specific to this command. 153#[derive(clap::Args, Clone, Debug)] 154pub struct GitFetchArgs { 155 /// Fetch only some of the branches 156 /// 157 /// By default, the specified name matches exactly. Use `glob:` prefix to 158 /// expand `*` as a glob. The other wildcard characters aren't supported. 159 #[arg(long, short, default_value = "glob:*", value_parser = StringPattern::parse)] 160 branch: Vec<StringPattern>, 161 /// The remote to fetch from (only named remotes are supported, can be 162 /// repeated) 163 #[arg(long = "remote", value_name = "remote")] 164 remotes: Vec<String>, 165 /// Fetch from all remotes 166 #[arg(long, conflicts_with = "remotes")] 167 all_remotes: bool, 168} 169 170/// Create a new repo backed by a clone of a Git repo 171/// 172/// The Git repo will be a bare git repo stored inside the `.jj/` directory. 173#[derive(clap::Args, Clone, Debug)] 174pub struct GitCloneArgs { 175 /// URL or path of the Git repo to clone 176 #[arg(value_hint = clap::ValueHint::DirPath)] 177 source: String, 178 /// The directory to write the Jujutsu repo to 179 #[arg(value_hint = clap::ValueHint::DirPath)] 180 destination: Option<String>, 181 /// Whether or not to colocate the Jujutsu repo with the git repo 182 #[arg(long)] 183 colocate: bool, 184} 185 186/// Push to a Git remote 187/// 188/// By default, pushes any branches pointing to 189/// `remote_branches(remote=<remote>)..@`. Use `--branch` to push specific 190/// branches. Use `--all` to push all branches. Use `--change` to generate 191/// branch names based on the change IDs of specific commits. 192#[derive(clap::Args, Clone, Debug)] 193#[command(group(ArgGroup::new("specific").args(&["branch", "change", "revisions"]).multiple(true)))] 194#[command(group(ArgGroup::new("what").args(&["all", "deleted", "tracked"]).conflicts_with("specific")))] 195pub struct GitPushArgs { 196 /// The remote to push to (only named remotes are supported) 197 #[arg(long)] 198 remote: Option<String>, 199 /// Push only this branch, or branches matching a pattern (can be repeated) 200 /// 201 /// By default, the specified name matches exactly. Use `glob:` prefix to 202 /// select branches by wildcard pattern. For details, see 203 /// https://martinvonz.github.io/jj/latest/revsets#string-patterns. 204 #[arg(long, short, value_parser = StringPattern::parse)] 205 branch: Vec<StringPattern>, 206 /// Push all branches (including deleted branches) 207 #[arg(long)] 208 all: bool, 209 /// Push all tracked branches (including deleted branches) 210 /// 211 /// This usually means that the branch was already pushed to or fetched from 212 /// the relevant remote. For details, see 213 /// https://martinvonz.github.io/jj/latest/branches#remotes-and-tracked-branches 214 #[arg(long)] 215 tracked: bool, 216 /// Push all deleted branches 217 /// 218 /// Only tracked branches can be successfully deleted on the remote. A 219 /// warning will be printed if any untracked branches on the remote 220 /// correspond to missing local branches. 221 #[arg(long)] 222 deleted: bool, 223 /// Push branches pointing to these commits (can be repeated) 224 #[arg(long, short)] 225 revisions: Vec<RevisionArg>, 226 /// Push this commit by creating a branch based on its change ID (can be 227 /// repeated) 228 #[arg(long, short)] 229 change: Vec<RevisionArg>, 230 /// Only display what will change on the remote 231 #[arg(long)] 232 dry_run: bool, 233} 234 235/// Update repo with changes made in the underlying Git repo 236/// 237/// If a working-copy commit gets abandoned, it will be given a new, empty 238/// commit. This is true in general; it is not specific to this command. 239#[derive(clap::Args, Clone, Debug)] 240pub struct GitImportArgs {} 241 242/// Update the underlying Git repo with changes made in the repo 243#[derive(clap::Args, Clone, Debug)] 244pub struct GitExportArgs {} 245 246/// FOR INTERNAL USE ONLY Interact with git submodules 247#[derive(Subcommand, Clone, Debug)] 248pub enum GitSubmoduleCommand { 249 /// Print the relevant contents from .gitmodules. For debugging purposes 250 /// only. 251 PrintGitmodules(GitSubmodulePrintGitmodulesArgs), 252} 253 254/// Print debugging info about Git submodules 255#[derive(clap::Args, Clone, Debug)] 256#[command(hide = true)] 257pub struct GitSubmodulePrintGitmodulesArgs { 258 /// Read .gitmodules from the given revision. 259 #[arg(long, short = 'r', default_value = "@")] 260 revisions: RevisionArg, 261} 262 263fn make_branch_term(branch_names: &[impl fmt::Display]) -> String { 264 match branch_names { 265 [branch_name] => format!("branch {}", branch_name), 266 branch_names => format!("branches {}", branch_names.iter().join(", ")), 267 } 268} 269 270fn map_git_error(err: git2::Error) -> CommandError { 271 if err.class() == git2::ErrorClass::Ssh { 272 let hint = 273 if err.code() == git2::ErrorCode::Certificate && std::env::var_os("HOME").is_none() { 274 "The HOME environment variable is not set, and might be required for Git to \ 275 successfully load certificates. Try setting it to the path of a directory that \ 276 contains a `.ssh` directory." 277 } else { 278 "Jujutsu uses libssh2, which doesn't respect ~/.ssh/config. Does `ssh -F \ 279 /dev/null` to the host work?" 280 }; 281 282 user_error_with_hint(err, hint) 283 } else { 284 user_error(err.to_string()) 285 } 286} 287 288pub fn maybe_add_gitignore(workspace_command: &WorkspaceCommandHelper) -> Result<(), CommandError> { 289 if workspace_command.working_copy_shared_with_git() { 290 std::fs::write( 291 workspace_command 292 .workspace_root() 293 .join(".jj") 294 .join(".gitignore"), 295 "/*\n", 296 ) 297 .map_err(|e| user_error_with_message("Failed to write .jj/.gitignore file", e)) 298 } else { 299 Ok(()) 300 } 301} 302 303fn cmd_git_remote_add( 304 ui: &mut Ui, 305 command: &CommandHelper, 306 args: &GitRemoteAddArgs, 307) -> Result<(), CommandError> { 308 let workspace_command = command.workspace_helper(ui)?; 309 let repo = workspace_command.repo(); 310 let git_repo = get_git_repo(repo.store())?; 311 git::add_remote(&git_repo, &args.remote, &args.url)?; 312 Ok(()) 313} 314 315fn cmd_git_remote_remove( 316 ui: &mut Ui, 317 command: &CommandHelper, 318 args: &GitRemoteRemoveArgs, 319) -> Result<(), CommandError> { 320 let mut workspace_command = command.workspace_helper(ui)?; 321 let repo = workspace_command.repo(); 322 let git_repo = get_git_repo(repo.store())?; 323 let mut tx = workspace_command.start_transaction(); 324 git::remove_remote(tx.mut_repo(), &git_repo, &args.remote)?; 325 if tx.mut_repo().has_changes() { 326 tx.finish(ui, format!("remove git remote {}", &args.remote)) 327 } else { 328 Ok(()) // Do not print "Nothing changed." 329 } 330} 331 332fn cmd_git_remote_rename( 333 ui: &mut Ui, 334 command: &CommandHelper, 335 args: &GitRemoteRenameArgs, 336) -> Result<(), CommandError> { 337 let mut workspace_command = command.workspace_helper(ui)?; 338 let repo = workspace_command.repo(); 339 let git_repo = get_git_repo(repo.store())?; 340 let mut tx = workspace_command.start_transaction(); 341 git::rename_remote(tx.mut_repo(), &git_repo, &args.old, &args.new)?; 342 if tx.mut_repo().has_changes() { 343 tx.finish( 344 ui, 345 format!("rename git remote {} to {}", &args.old, &args.new), 346 ) 347 } else { 348 Ok(()) // Do not print "Nothing changed." 349 } 350} 351 352fn cmd_git_remote_list( 353 ui: &mut Ui, 354 command: &CommandHelper, 355 _args: &GitRemoteListArgs, 356) -> Result<(), CommandError> { 357 let workspace_command = command.workspace_helper(ui)?; 358 let repo = workspace_command.repo(); 359 let git_repo = get_git_repo(repo.store())?; 360 for remote_name in git_repo.remotes()?.iter().flatten() { 361 let remote = git_repo.find_remote(remote_name)?; 362 writeln!( 363 ui.stdout(), 364 "{} {}", 365 remote_name, 366 remote.url().unwrap_or("<no URL>") 367 )?; 368 } 369 Ok(()) 370} 371 372pub fn git_init( 373 ui: &mut Ui, 374 command: &CommandHelper, 375 workspace_root: &Path, 376 colocate: bool, 377 git_repo: Option<&str>, 378) -> Result<(), CommandError> { 379 #[derive(Clone, Debug)] 380 enum GitInitMode { 381 Colocate, 382 External(PathBuf), 383 Internal, 384 } 385 386 let colocated_git_repo_path = workspace_root.join(".git"); 387 let init_mode = if colocate { 388 if colocated_git_repo_path.exists() { 389 GitInitMode::External(colocated_git_repo_path) 390 } else { 391 GitInitMode::Colocate 392 } 393 } else if let Some(path_str) = git_repo { 394 let mut git_repo_path = command.cwd().join(path_str); 395 if !git_repo_path.ends_with(".git") { 396 git_repo_path.push(".git"); 397 // Undo if .git doesn't exist - likely a bare repo. 398 if !git_repo_path.exists() { 399 git_repo_path.pop(); 400 } 401 } 402 GitInitMode::External(git_repo_path) 403 } else { 404 if colocated_git_repo_path.exists() { 405 return Err(user_error_with_hint( 406 "Did not create a jj repo because there is an existing Git repo in this directory.", 407 "To create a repo backed by the existing Git repo, run `jj git init --colocate` \ 408 instead.", 409 )); 410 } 411 GitInitMode::Internal 412 }; 413 414 match &init_mode { 415 GitInitMode::Colocate => { 416 let (workspace, repo) = 417 Workspace::init_colocated_git(command.settings(), workspace_root)?; 418 let workspace_command = command.for_loaded_repo(ui, workspace, repo)?; 419 maybe_add_gitignore(&workspace_command)?; 420 } 421 GitInitMode::External(git_repo_path) => { 422 let (workspace, repo) = 423 Workspace::init_external_git(command.settings(), workspace_root, git_repo_path)?; 424 // Import refs first so all the reachable commits are indexed in 425 // chronological order. 426 let colocated = is_colocated_git_workspace(&workspace, &repo); 427 let repo = init_git_refs(ui, command, repo, colocated)?; 428 let mut workspace_command = command.for_loaded_repo(ui, workspace, repo)?; 429 maybe_add_gitignore(&workspace_command)?; 430 workspace_command.maybe_snapshot(ui)?; 431 if !workspace_command.working_copy_shared_with_git() { 432 let mut tx = workspace_command.start_transaction(); 433 jj_lib::git::import_head(tx.mut_repo())?; 434 if let Some(git_head_id) = tx.mut_repo().view().git_head().as_normal().cloned() { 435 let git_head_commit = tx.mut_repo().store().get_commit(&git_head_id)?; 436 tx.check_out(&git_head_commit)?; 437 } 438 if tx.mut_repo().has_changes() { 439 tx.finish(ui, "import git head")?; 440 } 441 } 442 print_trackable_remote_branches(ui, workspace_command.repo().view())?; 443 } 444 GitInitMode::Internal => { 445 Workspace::init_internal_git(command.settings(), workspace_root)?; 446 } 447 } 448 Ok(()) 449} 450 451/// Imports branches and tags from the underlying Git repo, exports changes if 452/// the repo is colocated. 453/// 454/// This is similar to `WorkspaceCommandHelper::import_git_refs()`, but never 455/// moves the Git HEAD to the working copy parent. 456fn init_git_refs( 457 ui: &mut Ui, 458 command: &CommandHelper, 459 repo: Arc<ReadonlyRepo>, 460 colocated: bool, 461) -> Result<Arc<ReadonlyRepo>, CommandError> { 462 let mut tx = start_repo_transaction(&repo, command.settings(), command.string_args()); 463 // There should be no old refs to abandon, but enforce it. 464 let mut git_settings = command.settings().git_settings(); 465 git_settings.abandon_unreachable_commits = false; 466 let stats = git::import_some_refs( 467 tx.mut_repo(), 468 &git_settings, 469 // Initial import shouldn't fail because of reserved remote name. 470 |ref_name| !git::is_reserved_git_remote_ref(ref_name), 471 )?; 472 if !tx.mut_repo().has_changes() { 473 return Ok(repo); 474 } 475 print_git_import_stats(ui, tx.repo(), &stats, false)?; 476 if colocated { 477 // If git.auto-local-branch = true, local branches could be created for 478 // the imported remote branches. 479 let failed_branches = git::export_refs(tx.mut_repo())?; 480 print_failed_git_export(ui, &failed_branches)?; 481 } 482 let repo = tx.commit("import git refs"); 483 writeln!( 484 ui.status(), 485 "Done importing changes from the underlying Git repo." 486 )?; 487 Ok(repo) 488} 489 490fn cmd_git_init( 491 ui: &mut Ui, 492 command: &CommandHelper, 493 args: &GitInitArgs, 494) -> Result<(), CommandError> { 495 let cwd = command.cwd(); 496 let wc_path = cwd.join(&args.destination); 497 let wc_path = file_util::create_or_reuse_dir(&wc_path) 498 .and_then(|_| wc_path.canonicalize()) 499 .map_err(|e| user_error_with_message("Failed to create workspace", e))?; 500 501 git_init( 502 ui, 503 command, 504 &wc_path, 505 args.colocate, 506 args.git_repo.as_deref(), 507 )?; 508 509 let relative_wc_path = file_util::relative_path(cwd, &wc_path); 510 writeln!( 511 ui.status(), 512 r#"Initialized repo in "{}""#, 513 relative_wc_path.display() 514 )?; 515 516 Ok(()) 517} 518 519#[tracing::instrument(skip(ui, command))] 520fn cmd_git_fetch( 521 ui: &mut Ui, 522 command: &CommandHelper, 523 args: &GitFetchArgs, 524) -> Result<(), CommandError> { 525 let mut workspace_command = command.workspace_helper(ui)?; 526 let git_repo = get_git_repo(workspace_command.repo().store())?; 527 let remotes = if args.all_remotes { 528 get_all_remotes(&git_repo)? 529 } else if args.remotes.is_empty() { 530 get_default_fetch_remotes(ui, command.settings(), &git_repo)? 531 } else { 532 args.remotes.clone() 533 }; 534 let mut tx = workspace_command.start_transaction(); 535 for remote in &remotes { 536 let stats = with_remote_git_callbacks(ui, None, |cb| { 537 git::fetch( 538 tx.mut_repo(), 539 &git_repo, 540 remote, 541 &args.branch, 542 cb, 543 &command.settings().git_settings(), 544 ) 545 }) 546 .map_err(|err| match err { 547 GitFetchError::InvalidBranchPattern => { 548 if args 549 .branch 550 .iter() 551 .any(|pattern| pattern.as_exact().map_or(false, |s| s.contains('*'))) 552 { 553 user_error_with_hint( 554 err, 555 "Prefix the pattern with `glob:` to expand `*` as a glob", 556 ) 557 } else { 558 user_error(err) 559 } 560 } 561 GitFetchError::GitImportError(err) => err.into(), 562 GitFetchError::InternalGitError(err) => map_git_error(err), 563 _ => user_error(err), 564 })?; 565 print_git_import_stats(ui, tx.repo(), &stats.import_stats, true)?; 566 } 567 tx.finish( 568 ui, 569 format!("fetch from git remote(s) {}", remotes.iter().join(",")), 570 )?; 571 Ok(()) 572} 573 574fn get_single_remote(git_repo: &git2::Repository) -> Result<Option<String>, CommandError> { 575 let git_remotes = git_repo.remotes()?; 576 Ok(match git_remotes.len() { 577 1 => git_remotes.get(0).map(ToOwned::to_owned), 578 _ => None, 579 }) 580} 581 582const DEFAULT_REMOTE: &str = "origin"; 583 584fn get_default_fetch_remotes( 585 ui: &Ui, 586 settings: &UserSettings, 587 git_repo: &git2::Repository, 588) -> Result<Vec<String>, CommandError> { 589 const KEY: &str = "git.fetch"; 590 if let Ok(remotes) = settings.config().get(KEY) { 591 Ok(remotes) 592 } else if let Some(remote) = settings.config().get_string(KEY).optional()? { 593 Ok(vec![remote]) 594 } else if let Some(remote) = get_single_remote(git_repo)? { 595 // if nothing was explicitly configured, try to guess 596 if remote != DEFAULT_REMOTE { 597 if let Some(mut writer) = ui.hint_default() { 598 writeln!(writer, "Fetching from the only existing remote: {remote}")?; 599 } 600 } 601 Ok(vec![remote]) 602 } else { 603 Ok(vec![DEFAULT_REMOTE.to_owned()]) 604 } 605} 606 607fn get_all_remotes(git_repo: &git2::Repository) -> Result<Vec<String>, CommandError> { 608 let git_remotes = git_repo.remotes()?; 609 Ok(git_remotes 610 .iter() 611 .filter_map(|x| x.map(ToOwned::to_owned)) 612 .collect()) 613} 614 615fn absolute_git_source(cwd: &Path, source: &str) -> String { 616 // Git appears to turn URL-like source to absolute path if local git directory 617 // exits, and fails because '$PWD/https' is unsupported protocol. Since it would 618 // be tedious to copy the exact git (or libgit2) behavior, we simply assume a 619 // source containing ':' is a URL, SSH remote, or absolute path with Windows 620 // drive letter. 621 if !source.contains(':') && Path::new(source).exists() { 622 // It's less likely that cwd isn't utf-8, so just fall back to original source. 623 cwd.join(source) 624 .into_os_string() 625 .into_string() 626 .unwrap_or_else(|_| source.to_owned()) 627 } else { 628 source.to_owned() 629 } 630} 631 632fn clone_destination_for_source(source: &str) -> Option<&str> { 633 let destination = source.strip_suffix(".git").unwrap_or(source); 634 let destination = destination.strip_suffix('/').unwrap_or(destination); 635 destination 636 .rsplit_once(&['/', '\\', ':'][..]) 637 .map(|(_, name)| name) 638} 639 640fn is_empty_dir(path: &Path) -> bool { 641 if let Ok(mut entries) = path.read_dir() { 642 entries.next().is_none() 643 } else { 644 false 645 } 646} 647 648fn cmd_git_clone( 649 ui: &mut Ui, 650 command: &CommandHelper, 651 args: &GitCloneArgs, 652) -> Result<(), CommandError> { 653 let remote_name = "origin"; 654 let source = absolute_git_source(command.cwd(), &args.source); 655 let wc_path_str = args 656 .destination 657 .as_deref() 658 .or_else(|| clone_destination_for_source(&source)) 659 .ok_or_else(|| user_error("No destination specified and wasn't able to guess it"))?; 660 let wc_path = command.cwd().join(wc_path_str); 661 let wc_path_existed = match fs::create_dir(&wc_path) { 662 Ok(()) => false, 663 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => true, 664 Err(err) => { 665 return Err(user_error_with_message( 666 format!("Failed to create {wc_path_str}"), 667 err, 668 )); 669 } 670 }; 671 if wc_path_existed && !is_empty_dir(&wc_path) { 672 return Err(user_error( 673 "Destination path exists and is not an empty directory", 674 )); 675 } 676 677 // Canonicalize because fs::remove_dir_all() doesn't seem to like e.g. 678 // `/some/path/.` 679 let canonical_wc_path: PathBuf = wc_path 680 .canonicalize() 681 .map_err(|err| user_error_with_message(format!("Failed to create {wc_path_str}"), err))?; 682 let clone_result = do_git_clone( 683 ui, 684 command, 685 args.colocate, 686 remote_name, 687 &source, 688 &canonical_wc_path, 689 ); 690 if clone_result.is_err() { 691 let clean_up_dirs = || -> io::Result<()> { 692 fs::remove_dir_all(canonical_wc_path.join(".jj"))?; 693 if args.colocate { 694 fs::remove_dir_all(canonical_wc_path.join(".git"))?; 695 } 696 if !wc_path_existed { 697 fs::remove_dir(&canonical_wc_path)?; 698 } 699 Ok(()) 700 }; 701 if let Err(err) = clean_up_dirs() { 702 writeln!( 703 ui.warning_default(), 704 "Failed to clean up {}: {}", 705 canonical_wc_path.display(), 706 err 707 ) 708 .ok(); 709 } 710 } 711 712 let (mut workspace_command, stats) = clone_result?; 713 if let Some(default_branch) = &stats.default_branch { 714 let default_branch_remote_ref = workspace_command 715 .repo() 716 .view() 717 .get_remote_branch(default_branch, remote_name); 718 if let Some(commit_id) = default_branch_remote_ref.target.as_normal().cloned() { 719 let mut checkout_tx = workspace_command.start_transaction(); 720 // For convenience, create local branch as Git would do. 721 checkout_tx 722 .mut_repo() 723 .track_remote_branch(default_branch, remote_name); 724 if let Ok(commit) = checkout_tx.repo().store().get_commit(&commit_id) { 725 checkout_tx.check_out(&commit)?; 726 } 727 checkout_tx.finish(ui, "check out git remote's default branch")?; 728 } 729 } 730 Ok(()) 731} 732 733fn do_git_clone( 734 ui: &mut Ui, 735 command: &CommandHelper, 736 colocate: bool, 737 remote_name: &str, 738 source: &str, 739 wc_path: &Path, 740) -> Result<(WorkspaceCommandHelper, GitFetchStats), CommandError> { 741 let (workspace, repo) = if colocate { 742 Workspace::init_colocated_git(command.settings(), wc_path)? 743 } else { 744 Workspace::init_internal_git(command.settings(), wc_path)? 745 }; 746 let git_repo = get_git_repo(repo.store())?; 747 writeln!( 748 ui.status(), 749 r#"Fetching into new repo in "{}""#, 750 wc_path.display() 751 )?; 752 let mut workspace_command = command.for_loaded_repo(ui, workspace, repo)?; 753 maybe_add_gitignore(&workspace_command)?; 754 git_repo.remote(remote_name, source).unwrap(); 755 let mut fetch_tx = workspace_command.start_transaction(); 756 757 let stats = with_remote_git_callbacks(ui, None, |cb| { 758 git::fetch( 759 fetch_tx.mut_repo(), 760 &git_repo, 761 remote_name, 762 &[StringPattern::everything()], 763 cb, 764 &command.settings().git_settings(), 765 ) 766 }) 767 .map_err(|err| match err { 768 GitFetchError::NoSuchRemote(_) => { 769 panic!("shouldn't happen as we just created the git remote") 770 } 771 GitFetchError::GitImportError(err) => CommandError::from(err), 772 GitFetchError::InternalGitError(err) => map_git_error(err), 773 GitFetchError::InvalidBranchPattern => { 774 unreachable!("we didn't provide any globs") 775 } 776 })?; 777 print_git_import_stats(ui, fetch_tx.repo(), &stats.import_stats, true)?; 778 fetch_tx.finish(ui, "fetch from git remote into empty repo")?; 779 Ok((workspace_command, stats)) 780} 781 782fn cmd_git_push( 783 ui: &mut Ui, 784 command: &CommandHelper, 785 args: &GitPushArgs, 786) -> Result<(), CommandError> { 787 let mut workspace_command = command.workspace_helper(ui)?; 788 let git_repo = get_git_repo(workspace_command.repo().store())?; 789 790 let remote = if let Some(name) = &args.remote { 791 name.clone() 792 } else { 793 get_default_push_remote(ui, command.settings(), &git_repo)? 794 }; 795 796 let repo = workspace_command.repo().clone(); 797 let mut tx = workspace_command.start_transaction(); 798 let tx_description; 799 let mut branch_updates = vec![]; 800 if args.all { 801 for (branch_name, targets) in repo.view().local_remote_branches(&remote) { 802 match classify_branch_update(branch_name, &remote, targets) { 803 Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), 804 Ok(None) => {} 805 Err(reason) => reason.print(ui)?, 806 } 807 } 808 tx_description = format!("push all branches to git remote {remote}"); 809 } else if args.tracked { 810 for (branch_name, targets) in repo.view().local_remote_branches(&remote) { 811 if !targets.remote_ref.is_tracking() { 812 continue; 813 } 814 match classify_branch_update(branch_name, &remote, targets) { 815 Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), 816 Ok(None) => {} 817 Err(reason) => reason.print(ui)?, 818 } 819 } 820 tx_description = format!("push all tracked branches to git remote {remote}"); 821 } else if args.deleted { 822 for (branch_name, targets) in repo.view().local_remote_branches(&remote) { 823 if targets.local_target.is_present() { 824 continue; 825 } 826 match classify_branch_update(branch_name, &remote, targets) { 827 Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), 828 Ok(None) => {} 829 Err(reason) => reason.print(ui)?, 830 } 831 } 832 tx_description = format!("push all deleted branches to git remote {remote}"); 833 } else { 834 let mut seen_branches: HashSet<&str> = HashSet::new(); 835 836 // Process --change branches first because matching branches can be moved. 837 let change_branch_names = update_change_branches( 838 ui, 839 &mut tx, 840 &args.change, 841 &command.settings().push_branch_prefix(), 842 )?; 843 let change_branches = change_branch_names.iter().map(|branch_name| { 844 let targets = LocalAndRemoteRef { 845 local_target: tx.repo().view().get_local_branch(branch_name), 846 remote_ref: tx.repo().view().get_remote_branch(branch_name, &remote), 847 }; 848 (branch_name.as_ref(), targets) 849 }); 850 let branches_by_name = find_branches_to_push(repo.view(), &args.branch, &remote)?; 851 for (branch_name, targets) in change_branches.chain(branches_by_name.iter().copied()) { 852 if !seen_branches.insert(branch_name) { 853 continue; 854 } 855 match classify_branch_update(branch_name, &remote, targets) { 856 Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), 857 Ok(None) => writeln!( 858 ui.status(), 859 "Branch {branch_name}@{remote} already matches {branch_name}", 860 )?, 861 Err(reason) => return Err(reason.into()), 862 } 863 } 864 865 let use_default_revset = 866 args.branch.is_empty() && args.change.is_empty() && args.revisions.is_empty(); 867 let branches_targeted = find_branches_targeted_by_revisions( 868 ui, 869 tx.base_workspace_helper(), 870 &remote, 871 &args.revisions, 872 use_default_revset, 873 )?; 874 for &(branch_name, targets) in &branches_targeted { 875 if !seen_branches.insert(branch_name) { 876 continue; 877 } 878 match classify_branch_update(branch_name, &remote, targets) { 879 Ok(Some(update)) => branch_updates.push((branch_name.to_owned(), update)), 880 Ok(None) => {} 881 Err(reason) => reason.print(ui)?, 882 } 883 } 884 885 tx_description = format!( 886 "push {} to git remote {}", 887 make_branch_term( 888 &branch_updates 889 .iter() 890 .map(|(branch, _)| branch.as_str()) 891 .collect_vec() 892 ), 893 &remote 894 ); 895 } 896 if branch_updates.is_empty() { 897 writeln!(ui.status(), "Nothing changed.")?; 898 return Ok(()); 899 } 900 901 let mut new_heads = vec![]; 902 let mut force_pushed_branches = hashset! {}; 903 for (branch_name, update) in &branch_updates { 904 if let Some(new_target) = &update.new_target { 905 new_heads.push(new_target.clone()); 906 let force = match &update.old_target { 907 None => false, 908 Some(old_target) => !repo.index().is_ancestor(old_target, new_target), 909 }; 910 if force { 911 force_pushed_branches.insert(branch_name.to_string()); 912 } 913 } 914 } 915 916 // Check if there are conflicts in any commits we're about to push that haven't 917 // already been pushed. 918 let mut old_heads = repo 919 .view() 920 .remote_branches(&remote) 921 .flat_map(|(_, old_head)| old_head.target.added_ids()) 922 .cloned() 923 .collect_vec(); 924 if old_heads.is_empty() { 925 old_heads.push(repo.store().root_commit_id().clone()); 926 } 927 for commit in revset::walk_revs(repo.as_ref(), &new_heads, &old_heads)? 928 .iter() 929 .commits(repo.store()) 930 { 931 let commit = commit?; 932 let mut reasons = vec![]; 933 if commit.description().is_empty() { 934 reasons.push("it has no description"); 935 } 936 if commit.author().name.is_empty() 937 || commit.author().name == UserSettings::USER_NAME_PLACEHOLDER 938 || commit.author().email.is_empty() 939 || commit.author().email == UserSettings::USER_EMAIL_PLACEHOLDER 940 || commit.committer().name.is_empty() 941 || commit.committer().name == UserSettings::USER_NAME_PLACEHOLDER 942 || commit.committer().email.is_empty() 943 || commit.committer().email == UserSettings::USER_EMAIL_PLACEHOLDER 944 { 945 reasons.push("it has no author and/or committer set"); 946 } 947 if commit.has_conflict()? { 948 reasons.push("it has conflicts"); 949 } 950 if !reasons.is_empty() { 951 return Err(user_error(format!( 952 "Won't push commit {} since {}", 953 short_commit_hash(commit.id()), 954 reasons.join(" and ") 955 ))); 956 } 957 } 958 959 writeln!(ui.status(), "Branch changes to push to {}:", &remote)?; 960 for (branch_name, update) in &branch_updates { 961 match (&update.old_target, &update.new_target) { 962 (Some(old_target), Some(new_target)) => { 963 if force_pushed_branches.contains(branch_name) { 964 writeln!( 965 ui.status(), 966 " Force branch {branch_name} from {} to {}", 967 short_commit_hash(old_target), 968 short_commit_hash(new_target) 969 )?; 970 } else { 971 writeln!( 972 ui.status(), 973 " Move branch {branch_name} from {} to {}", 974 short_commit_hash(old_target), 975 short_commit_hash(new_target) 976 )?; 977 } 978 } 979 (Some(old_target), None) => { 980 writeln!( 981 ui.status(), 982 " Delete branch {branch_name} from {}", 983 short_commit_hash(old_target) 984 )?; 985 } 986 (None, Some(new_target)) => { 987 writeln!( 988 ui.status(), 989 " Add branch {branch_name} to {}", 990 short_commit_hash(new_target) 991 )?; 992 } 993 (None, None) => { 994 panic!("Not pushing any change to branch {branch_name}"); 995 } 996 } 997 } 998 999 if args.dry_run { 1000 writeln!(ui.status(), "Dry-run requested, not pushing.")?; 1001 return Ok(()); 1002 } 1003 1004 let targets = GitBranchPushTargets { 1005 branch_updates, 1006 force_pushed_branches, 1007 }; 1008 let mut writer = GitSidebandProgressMessageWriter::new(ui); 1009 let mut sideband_progress_callback = |progress_message: &[u8]| { 1010 _ = writer.write(ui, progress_message); 1011 }; 1012 with_remote_git_callbacks(ui, Some(&mut sideband_progress_callback), |cb| { 1013 git::push_branches(tx.mut_repo(), &git_repo, &remote, &targets, cb) 1014 }) 1015 .map_err(|err| match err { 1016 GitPushError::InternalGitError(err) => map_git_error(err), 1017 GitPushError::NotFastForward => user_error_with_hint( 1018 "The push conflicts with changes made on the remote (it is not fast-forwardable).", 1019 "Try fetching from the remote, then make the branch point to where you want it to be, \ 1020 and push again.", 1021 ), 1022 _ => user_error(err), 1023 })?; 1024 writer.flush(ui)?; 1025 tx.finish(ui, tx_description)?; 1026 Ok(()) 1027} 1028 1029fn get_default_push_remote( 1030 ui: &Ui, 1031 settings: &UserSettings, 1032 git_repo: &git2::Repository, 1033) -> Result<String, CommandError> { 1034 if let Some(remote) = settings.config().get_string("git.push").optional()? { 1035 Ok(remote) 1036 } else if let Some(remote) = get_single_remote(git_repo)? { 1037 // similar to get_default_fetch_remotes 1038 if remote != DEFAULT_REMOTE { 1039 if let Some(mut writer) = ui.hint_default() { 1040 writeln!(writer, "Pushing to the only existing remote: {remote}")?; 1041 } 1042 } 1043 Ok(remote) 1044 } else { 1045 Ok(DEFAULT_REMOTE.to_owned()) 1046 } 1047} 1048 1049#[derive(Clone, Debug)] 1050struct RejectedBranchUpdateReason { 1051 message: String, 1052 hint: Option<String>, 1053} 1054 1055impl RejectedBranchUpdateReason { 1056 fn print(&self, ui: &Ui) -> io::Result<()> { 1057 writeln!(ui.warning_default(), "{}", self.message)?; 1058 if let Some(hint) = &self.hint { 1059 if let Some(mut writer) = ui.hint_default() { 1060 writeln!(writer, "{hint}")?; 1061 } 1062 } 1063 Ok(()) 1064 } 1065} 1066 1067impl From<RejectedBranchUpdateReason> for CommandError { 1068 fn from(reason: RejectedBranchUpdateReason) -> Self { 1069 let RejectedBranchUpdateReason { message, hint } = reason; 1070 let mut cmd_err = user_error(message); 1071 cmd_err.extend_hints(hint); 1072 cmd_err 1073 } 1074} 1075 1076fn classify_branch_update( 1077 branch_name: &str, 1078 remote_name: &str, 1079 targets: LocalAndRemoteRef, 1080) -> Result<Option<BranchPushUpdate>, RejectedBranchUpdateReason> { 1081 let push_action = classify_branch_push_action(targets); 1082 match push_action { 1083 BranchPushAction::AlreadyMatches => Ok(None), 1084 BranchPushAction::LocalConflicted => Err(RejectedBranchUpdateReason { 1085 message: format!("Branch {branch_name} is conflicted"), 1086 hint: Some( 1087 "Run `jj branch list` to inspect, and use `jj branch set` to fix it up.".to_owned(), 1088 ), 1089 }), 1090 BranchPushAction::RemoteConflicted => Err(RejectedBranchUpdateReason { 1091 message: format!("Branch {branch_name}@{remote_name} is conflicted"), 1092 hint: Some("Run `jj git fetch` to update the conflicted remote branch.".to_owned()), 1093 }), 1094 BranchPushAction::RemoteUntracked => Err(RejectedBranchUpdateReason { 1095 message: format!("Non-tracking remote branch {branch_name}@{remote_name} exists"), 1096 hint: Some(format!( 1097 "Run `jj branch track {branch_name}@{remote_name}` to import the remote branch." 1098 )), 1099 }), 1100 BranchPushAction::Update(update) => Ok(Some(update)), 1101 } 1102} 1103 1104/// Creates or moves branches based on the change IDs. 1105fn update_change_branches( 1106 ui: &Ui, 1107 tx: &mut WorkspaceCommandTransaction, 1108 changes: &[RevisionArg], 1109 branch_prefix: &str, 1110) -> Result<Vec<String>, CommandError> { 1111 let mut branch_names = Vec::new(); 1112 for change_arg in changes { 1113 let workspace_command = tx.base_workspace_helper(); 1114 let commit = workspace_command.resolve_single_rev(change_arg)?; 1115 let mut branch_name = format!("{branch_prefix}{}", commit.change_id().hex()); 1116 let view = tx.base_repo().view(); 1117 if view.get_local_branch(&branch_name).is_absent() { 1118 // A local branch with the full change ID doesn't exist already, so use the 1119 // short ID if it's not ambiguous (which it shouldn't be most of the time). 1120 let short_change_id = short_change_hash(commit.change_id()); 1121 if workspace_command 1122 .resolve_single_rev(&RevisionArg::from(short_change_id.clone())) 1123 .is_ok() 1124 { 1125 // Short change ID is not ambiguous, so update the branch name to use it. 1126 branch_name = format!("{branch_prefix}{short_change_id}"); 1127 }; 1128 } 1129 if view.get_local_branch(&branch_name).is_absent() { 1130 writeln!( 1131 ui.status(), 1132 "Creating branch {branch_name} for revision {change_arg}", 1133 )?; 1134 } 1135 tx.mut_repo() 1136 .set_local_branch_target(&branch_name, RefTarget::normal(commit.id().clone())); 1137 branch_names.push(branch_name); 1138 } 1139 Ok(branch_names) 1140} 1141 1142fn find_branches_to_push<'a>( 1143 view: &'a View, 1144 branch_patterns: &[StringPattern], 1145 remote_name: &str, 1146) -> Result<Vec<(&'a str, LocalAndRemoteRef<'a>)>, CommandError> { 1147 let mut matching_branches = vec![]; 1148 let mut unmatched_patterns = vec![]; 1149 for pattern in branch_patterns { 1150 let mut matches = view 1151 .local_remote_branches_matching(pattern, remote_name) 1152 .filter(|(_, targets)| { 1153 // If the remote exists but is not tracking, the absent local shouldn't 1154 // be considered a deleted branch. 1155 targets.local_target.is_present() || targets.remote_ref.is_tracking() 1156 }) 1157 .peekable(); 1158 if matches.peek().is_none() { 1159 unmatched_patterns.push(pattern); 1160 } 1161 matching_branches.extend(matches); 1162 } 1163 match &unmatched_patterns[..] { 1164 [] => Ok(matching_branches), 1165 [pattern] if pattern.is_exact() => Err(user_error(format!("No such branch: {pattern}"))), 1166 patterns => Err(user_error(format!( 1167 "No matching branches for patterns: {}", 1168 patterns.iter().join(", ") 1169 ))), 1170 } 1171} 1172 1173fn find_branches_targeted_by_revisions<'a>( 1174 ui: &Ui, 1175 workspace_command: &'a WorkspaceCommandHelper, 1176 remote_name: &str, 1177 revisions: &[RevisionArg], 1178 use_default_revset: bool, 1179) -> Result<Vec<(&'a str, LocalAndRemoteRef<'a>)>, CommandError> { 1180 let mut revision_commit_ids = HashSet::new(); 1181 if use_default_revset { 1182 let Some(wc_commit_id) = workspace_command.get_wc_commit_id().cloned() else { 1183 return Err(user_error("Nothing checked out in this workspace")); 1184 }; 1185 let current_branches_expression = RevsetExpression::remote_branches( 1186 StringPattern::everything(), 1187 StringPattern::Exact(remote_name.to_owned()), 1188 ) 1189 .range(&RevsetExpression::commit(wc_commit_id)) 1190 .intersection(&RevsetExpression::branches(StringPattern::everything())); 1191 let current_branches_revset = 1192 current_branches_expression.evaluate_programmatic(workspace_command.repo().as_ref())?; 1193 revision_commit_ids.extend(current_branches_revset.iter()); 1194 if revision_commit_ids.is_empty() { 1195 writeln!( 1196 ui.warning_default(), 1197 "No branches found in the default push revset: \ 1198 remote_branches(remote={remote_name})..@" 1199 )?; 1200 } 1201 } 1202 for rev_arg in revisions { 1203 let mut expression = workspace_command.parse_revset(rev_arg)?; 1204 expression.intersect_with(&RevsetExpression::branches(StringPattern::everything())); 1205 let mut commit_ids = expression.evaluate_to_commit_ids()?.peekable(); 1206 if commit_ids.peek().is_none() { 1207 writeln!( 1208 ui.warning_default(), 1209 "No branches point to the specified revisions: {rev_arg}" 1210 )?; 1211 } 1212 revision_commit_ids.extend(commit_ids); 1213 } 1214 let branches_targeted = workspace_command 1215 .repo() 1216 .view() 1217 .local_remote_branches(remote_name) 1218 .filter(|(_, targets)| { 1219 let mut local_ids = targets.local_target.added_ids(); 1220 local_ids.any(|id| revision_commit_ids.contains(id)) 1221 }) 1222 .collect_vec(); 1223 Ok(branches_targeted) 1224} 1225 1226fn cmd_git_import( 1227 ui: &mut Ui, 1228 command: &CommandHelper, 1229 _args: &GitImportArgs, 1230) -> Result<(), CommandError> { 1231 let mut workspace_command = command.workspace_helper(ui)?; 1232 let mut tx = workspace_command.start_transaction(); 1233 // In non-colocated repo, HEAD@git will never be moved internally by jj. 1234 // That's why cmd_git_export() doesn't export the HEAD ref. 1235 git::import_head(tx.mut_repo())?; 1236 let stats = git::import_refs(tx.mut_repo(), &command.settings().git_settings())?; 1237 print_git_import_stats(ui, tx.repo(), &stats, true)?; 1238 tx.finish(ui, "import git refs")?; 1239 Ok(()) 1240} 1241 1242fn cmd_git_export( 1243 ui: &mut Ui, 1244 command: &CommandHelper, 1245 _args: &GitExportArgs, 1246) -> Result<(), CommandError> { 1247 let mut workspace_command = command.workspace_helper(ui)?; 1248 let mut tx = workspace_command.start_transaction(); 1249 let failed_branches = git::export_refs(tx.mut_repo())?; 1250 tx.finish(ui, "export git refs")?; 1251 print_failed_git_export(ui, &failed_branches)?; 1252 Ok(()) 1253} 1254 1255fn cmd_git_submodule_print_gitmodules( 1256 ui: &mut Ui, 1257 command: &CommandHelper, 1258 args: &GitSubmodulePrintGitmodulesArgs, 1259) -> Result<(), CommandError> { 1260 let workspace_command = command.workspace_helper(ui)?; 1261 let repo = workspace_command.repo(); 1262 let commit = workspace_command.resolve_single_rev(&args.revisions)?; 1263 let tree = commit.tree()?; 1264 let gitmodules_path = RepoPath::from_internal_string(".gitmodules"); 1265 let mut gitmodules_file = match tree.path_value(gitmodules_path).into_resolved() { 1266 Ok(None) => { 1267 writeln!(ui.status(), "No submodules!")?; 1268 return Ok(()); 1269 } 1270 Ok(Some(TreeValue::File { id, .. })) => repo.store().read_file(gitmodules_path, &id)?, 1271 _ => { 1272 return Err(user_error(".gitmodules is not a file.")); 1273 } 1274 }; 1275 1276 let submodules = parse_gitmodules(&mut gitmodules_file)?; 1277 for (name, submodule) in submodules { 1278 writeln!( 1279 ui.stdout(), 1280 "name:{}\nurl:{}\npath:{}\n\n", 1281 name, 1282 submodule.url, 1283 submodule.path 1284 )?; 1285 } 1286 Ok(()) 1287} 1288 1289pub fn cmd_git( 1290 ui: &mut Ui, 1291 command: &CommandHelper, 1292 subcommand: &GitCommand, 1293) -> Result<(), CommandError> { 1294 match subcommand { 1295 GitCommand::Init(args) => cmd_git_init(ui, command, args), 1296 GitCommand::Fetch(args) => cmd_git_fetch(ui, command, args), 1297 GitCommand::Clone(args) => cmd_git_clone(ui, command, args), 1298 GitCommand::Remote(GitRemoteCommand::Add(args)) => cmd_git_remote_add(ui, command, args), 1299 GitCommand::Remote(GitRemoteCommand::Remove(args)) => { 1300 cmd_git_remote_remove(ui, command, args) 1301 } 1302 GitCommand::Remote(GitRemoteCommand::Rename(args)) => { 1303 cmd_git_remote_rename(ui, command, args) 1304 } 1305 GitCommand::Remote(GitRemoteCommand::List(args)) => cmd_git_remote_list(ui, command, args), 1306 GitCommand::Push(args) => cmd_git_push(ui, command, args), 1307 GitCommand::Import(args) => cmd_git_import(ui, command, args), 1308 GitCommand::Export(args) => cmd_git_export(ui, command, args), 1309 GitCommand::Submodule(GitSubmoduleCommand::PrintGitmodules(args)) => { 1310 cmd_git_submodule_print_gitmodules(ui, command, args) 1311 } 1312 } 1313}