just playing with tangled
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}