diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f451f471..ee8a916594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). can be customized using `format_detailed_cryptographic_signature(signature)` and `format_short_cryptographic_signature(signature)`. +* New `git.init-track-local-bookmarks` config option to automatically set local + bookmarks as tracking remote bookmarks during `jj git init --colocate`. + ### Fixed bugs * Fixed diff selection by external tools with `jj split`/`commit -i FILESETS`. diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index af04ee8bcc..f388c96d19 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -115,7 +115,6 @@ use jj_lib::settings::HumanByteSize; use jj_lib::settings::UserSettings; use jj_lib::str_util::StringPattern; use jj_lib::transaction::Transaction; -use jj_lib::view::View; use jj_lib::working_copy; use jj_lib::working_copy::CheckoutOptions; use jj_lib::working_copy::CheckoutStats; @@ -2755,40 +2754,6 @@ pub fn print_unmatched_explicit_paths<'a>( Ok(()) } -pub fn print_trackable_remote_bookmarks(ui: &Ui, view: &View) -> io::Result<()> { - let remote_bookmark_names = view - .bookmarks() - .filter(|(_, bookmark_target)| bookmark_target.local_target.is_present()) - .flat_map(|(name, bookmark_target)| { - bookmark_target - .remote_refs - .into_iter() - .filter(|&(_, remote_ref)| !remote_ref.is_tracking()) - .map(move |(remote, _)| format!("{name}@{remote}")) - }) - .collect_vec(); - if remote_bookmark_names.is_empty() { - return Ok(()); - } - - if let Some(mut formatter) = ui.status_formatter() { - writeln!( - formatter.labeled("hint").with_heading("Hint: "), - "The following remote bookmarks aren't associated with the existing local bookmarks:" - )?; - for full_name in &remote_bookmark_names { - write!(formatter, " ")?; - writeln!(formatter.labeled("bookmark"), "{full_name}")?; - } - writeln!( - formatter.labeled("hint").with_heading("Hint: "), - "Run `jj bookmark track {names}` to keep local bookmarks updated on future pulls.", - names = remote_bookmark_names.join(" "), - )?; - } - Ok(()) -} - pub fn update_working_copy( repo: &Arc, workspace: &mut Workspace, diff --git a/cli/src/commands/git/init.rs b/cli/src/commands/git/init.rs index aed744dd2d..41867e92e6 100644 --- a/cli/src/commands/git/init.rs +++ b/cli/src/commands/git/init.rs @@ -12,21 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::io; use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use itertools::Itertools; use jj_lib::file_util; use jj_lib::git; use jj_lib::git::parse_git_ref; use jj_lib::git::RefName; +use jj_lib::op_store::BookmarkTarget; +use jj_lib::op_store::RefTarget; +use jj_lib::op_store::RemoteRef; +use jj_lib::repo::MutableRepo; use jj_lib::repo::ReadonlyRepo; use jj_lib::repo::Repo; +use jj_lib::revset::RevsetExpression; +use jj_lib::view::View; use jj_lib::workspace::Workspace; use super::write_repository_level_trunk_alias; -use crate::cli_util::print_trackable_remote_bookmarks; use crate::cli_util::start_repo_transaction; use crate::cli_util::CommandHelper; use crate::cli_util::WorkspaceCommandHelper; @@ -35,6 +42,7 @@ use crate::command_error::user_error_with_hint; use crate::command_error::user_error_with_message; use crate::command_error::CommandError; use crate::commands::git::maybe_add_gitignore; +use crate::formatter::Formatter; use crate::git_util::get_git_repo; use crate::git_util::is_colocated_git_workspace; use crate::git_util::print_failed_git_export; @@ -172,18 +180,23 @@ fn do_init( maybe_add_gitignore(&workspace_command)?; workspace_command.maybe_snapshot(ui)?; maybe_set_repository_level_trunk_alias(ui, &workspace_command)?; - if !workspace_command.working_copy_shared_with_git() { - let mut tx = workspace_command.start_transaction(); + let working_copy_shared_with_git = workspace_command.working_copy_shared_with_git(); + let mut tx = workspace_command.start_transaction(); + if !working_copy_shared_with_git { jj_lib::git::import_head(tx.repo_mut())?; if let Some(git_head_id) = tx.repo().view().git_head().as_normal().cloned() { let git_head_commit = tx.repo().store().get_commit(&git_head_id)?; tx.check_out(&git_head_commit)?; } - if tx.repo().has_changes() { - tx.finish(ui, "import git head")?; - } } - print_trackable_remote_bookmarks(ui, workspace_command.repo().view())?; + if settings.get_bool("git.init-track-local-bookmarks")? { + track_trackable_remote_bookmarks(ui, tx.repo_mut())?; + } else { + print_trackable_remote_bookmarks(ui, tx.repo().view())?; + } + if tx.repo().has_changes() { + tx.finish(ui, "import git head")?; + } } GitInitMode::Internal => { Workspace::init_internal_git(&settings, workspace_root)?; @@ -252,3 +265,123 @@ pub fn maybe_set_repository_level_trunk_alias( Ok(()) } + +fn present_local_bookmarks(view: &View) -> impl Iterator { + view.bookmarks() + .filter(|(_, bookmark_target)| bookmark_target.local_target.is_present()) +} + +fn print_trackable_remote_bookmarks(ui: &Ui, view: &View) -> io::Result<()> { + let remote_bookmark_names = present_local_bookmarks(view) + .flat_map(|(name, bookmark_target)| { + bookmark_target + .remote_refs + .into_iter() + .filter(|&(_, remote_ref)| !remote_ref.is_tracking()) + .map(move |(remote, _)| format!("{name}@{remote}")) + }) + .collect_vec(); + if remote_bookmark_names.is_empty() { + return Ok(()); + } + + if let Some(mut formatter) = ui.status_formatter() { + print_skipped_tracking(formatter.as_mut(), &remote_bookmark_names)?; + } + Ok(()) +} + +fn track_trackable_remote_bookmarks( + ui: &mut Ui, + repo: &mut MutableRepo, +) -> Result<(), CommandError> { + let present_local_bookmarks = present_local_bookmarks(repo.view()); + let (remote_bookmarks, skipped_tracking): (Vec<_>, Vec<_>) = present_local_bookmarks + .flat_map(|(name, bookmark_target)| { + let repo: &_ = repo; + let local_target = bookmark_target.local_target; + bookmark_target + .remote_refs + .into_iter() + .filter_map(|(remote_name, remote_ref)| { + should_track(repo, name, local_target, remote_name, remote_ref) + }) + }) + .partition_result(); + + if let Some(mut formatter) = ui.status_formatter() { + if !remote_bookmarks.is_empty() { + writeln!(formatter, "Tracking the following remote bookmarks:")?; + for (name, remote_name) in &remote_bookmarks { + write!(formatter, " ")?; + writeln!(formatter.labeled("bookmark"), "{name}@{remote_name}")?; + } + } + + print_skipped_tracking(formatter.as_mut(), &skipped_tracking)?; + } + + for (name, remote_name) in &remote_bookmarks { + repo.track_remote_bookmark(name, remote_name); + } + + Ok(()) +} + +fn print_skipped_tracking( + formatter: &mut dyn Formatter, + remote_bookmark_names: &[String], +) -> Result<(), io::Error> { + if remote_bookmark_names.is_empty() { + return Ok(()); + } + + writeln!( + formatter.labeled("hint").with_heading("Hint: "), + "The following remote bookmarks aren't associated with the existing local bookmarks:" + )?; + for full_name in remote_bookmark_names { + write!(formatter, " ")?; + writeln!(formatter.labeled("bookmark"), "{full_name}")?; + } + writeln!( + formatter.labeled("hint").with_heading("Hint: "), + "Run `jj bookmark track {names}` to keep local bookmarks updated on future pulls.", + names = remote_bookmark_names.join(" "), + )?; + Ok(()) +} + +/// Returns: +/// `None`: already tracking, or name@git +/// `Some(Err("name@remote"))`: should skip tracking, local is behind +/// `Some(Ok((name, remote)))`: should track +fn should_track( + repo: &dyn Repo, + name: &str, + local_target: &RefTarget, + remote_name: &str, + remote_ref: &RemoteRef, +) -> Option> { + if remote_ref.is_tracking() || jj_lib::git::is_special_git_remote(remote_name) { + return None; + } + + let remote_added = remote_ref.target.added_ids().cloned().collect(); + let Ok(ahead_of_remote) = RevsetExpression::commits(remote_added) + .descendants() + .evaluate(repo) + .map(|r| r.containing_fn()) + else { + return Some(Err(format!("{name}@{remote_name}"))); + }; + + if local_target + .added_ids() + .any(|add| ahead_of_remote(add).unwrap_or(false)) + { + Some(Ok((name.to_owned(), remote_name.to_owned()))) + } else { + Some(Err(format!("{name}@{remote_name}"))) + } +} diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json index 9264690050..97bae742b7 100644 --- a/cli/src/config-schema.json +++ b/cli/src/config-schema.json @@ -343,6 +343,11 @@ "description": "Whether jj should abandon commits that became unreachable in Git.", "default": true }, + "init-track-local-bookmarks": { + "type": "boolean", + "description": "Whether jj should track bookmarks that were already tracked when initializing a colocated repository.", + "default": false + }, "push-bookmark-prefix": { "type": "string", "description": "Prefix used when pushing a bookmark based on a change ID", diff --git a/cli/src/config/misc.toml b/cli/src/config/misc.toml index c5685b38a9..52a0992567 100644 --- a/cli/src/config/misc.toml +++ b/cli/src/config/misc.toml @@ -18,6 +18,7 @@ private-commits = "none()" push-bookmark-prefix = "push-" push-new-bookmarks = false sign-on-push = false +init-track-local-bookmarks = false [ui] # TODO: delete ui.allow-filesets in jj 0.26+ diff --git a/cli/tests/test_git_init.rs b/cli/tests/test_git_init.rs index 6d069a85bd..e747f1b9b3 100644 --- a/cli/tests/test_git_init.rs +++ b/cli/tests/test_git_init.rs @@ -745,6 +745,136 @@ fn test_git_init_colocated_via_flag_git_dir_exists() { "#); } +#[test] +fn test_git_init_colocated_import_branches() { + let test_env = TestEnvironment::default(); + + let origin_root = test_env.env_root().join("origin"); + init_git_repo(&origin_root, true); + + let workspace_root = test_env.env_root().join("repo_track"); + git2::Repository::clone(origin_root.to_str().unwrap(), &workspace_root).unwrap(); + + let (stdout, stderr) = test_env.jj_cmd_ok( + test_env.env_root(), + &[ + "git", + "init", + "--colocate", + "repo_track", + "--config", + "git.init-track-local-bookmarks=true", + ], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Done importing changes from the underlying Git repo. + Setting the revset alias "trunk()" to "my-bookmark@origin" + Tracking the following remote bookmarks: + my-bookmark@origin + Initialized repo in "repo_track" + "#); + + // Check that the bookmark is tracked + let stdout = test_env.jj_cmd_success(&workspace_root, &["bookmark", "list", "--all"]); + insta::assert_snapshot!(stdout, @r#" + my-bookmark: mwrttmos 8d698d4a My commit message + @git: mwrttmos 8d698d4a My commit message + @origin: mwrttmos 8d698d4a My commit message + "#); + + let workspace_root = test_env.env_root().join("repo_no_track"); + git2::Repository::clone(origin_root.to_str().unwrap(), &workspace_root).unwrap(); + + let (stdout, stderr) = test_env.jj_cmd_ok( + test_env.env_root(), + &[ + "git", + "init", + "--colocate", + "repo_no_track", + "--config", + "git.init-track-local-bookmarks=false", + ], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Done importing changes from the underlying Git repo. + Setting the revset alias "trunk()" to "my-bookmark@origin" + Hint: The following remote bookmarks aren't associated with the existing local bookmarks: + my-bookmark@origin + Hint: Run `jj bookmark track my-bookmark@origin` to keep local bookmarks updated on future pulls. + Initialized repo in "repo_no_track" + "#); + + // Check that the bookmark is *not* tracked + let stdout = test_env.jj_cmd_success(&workspace_root, &["bookmark", "list", "--all"]); + insta::assert_snapshot!(stdout, @r#" + my-bookmark: mwrttmos 8d698d4a My commit message + @git: mwrttmos 8d698d4a My commit message + my-bookmark@origin: mwrttmos 8d698d4a My commit message + "#); + + let workspace_root = test_env.env_root().join("repo_track_local_ahead"); + clone_and_append_git_commit(&origin_root, &workspace_root); + let (stdout, stderr) = test_env.jj_cmd_ok( + test_env.env_root(), + &[ + "git", + "init", + "--colocate", + "repo_track_local_ahead", + "--config", + "git.init-track-local-bookmarks=true", + ], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Done importing changes from the underlying Git repo. + Setting the revset alias "trunk()" to "my-bookmark@origin" + Tracking the following remote bookmarks: + my-bookmark@origin + Initialized repo in "repo_track_local_ahead" + "#); + + // Check that the bookmark is tracked, and local is ahead + let stdout = test_env.jj_cmd_success(&workspace_root, &["bookmark", "list", "--all"]); + insta::assert_snapshot!(stdout, @r#" + my-bookmark: sktoyuxv 7dfa7468 My new commit message + @git: sktoyuxv 7dfa7468 My new commit message + @origin (behind by 1 commits): mwrttmos 8d698d4a My commit message + "#); +} + +fn clone_and_append_git_commit(origin_root: &Path, workspace_root: &Path) { + let git_repo = git2::Repository::clone(origin_root.to_str().unwrap(), workspace_root).unwrap(); + let git_blob_oid = git_repo.blob(b"some different content").unwrap(); + let mut git_tree_builder = git_repo.treebuilder(None).unwrap(); + git_tree_builder + .insert("some-file", git_blob_oid, 0o100644) + .unwrap(); + let git_tree_id = git_tree_builder.write().unwrap(); + drop(git_tree_builder); + let git_tree = git_repo.find_tree(git_tree_id).unwrap(); + let git_signature = git2::Signature::new( + "Git User", + "git.user@example.com", + &git2::Time::new(124, 60), + ) + .unwrap(); + let head = git_repo.head().unwrap().peel_to_commit().unwrap(); + git_repo + .commit( + Some("refs/heads/my-bookmark"), + &git_signature, + &git_signature, + "My new commit message", + &git_tree, + &[&head], + ) + .unwrap(); +} + #[test] fn test_git_init_colocated_via_flag_git_dir_not_exists() { let test_env = TestEnvironment::default();