diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b040bc359..3e3c9514990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New features +* Implement `jj git sync`. + * Templates now support the `==` and `!=` logical operators for `Boolean`, `Integer`, and `String` types. diff --git a/cli/src/commands/git/mod.rs b/cli/src/commands/git/mod.rs index 3aa6105ec14..da2c783600b 100644 --- a/cli/src/commands/git/mod.rs +++ b/cli/src/commands/git/mod.rs @@ -20,6 +20,7 @@ pub mod init; pub mod push; pub mod remote; pub mod submodule; +pub mod sync; use clap::Subcommand; @@ -39,6 +40,8 @@ use self::remote::cmd_git_remote; use self::remote::RemoteCommand; use self::submodule::cmd_git_submodule; use self::submodule::GitSubmoduleCommand; +use self::sync::cmd_git_sync; +use self::sync::GitSyncArgs; use crate::cli_util::CommandHelper; use crate::cli_util::WorkspaceCommandHelper; use crate::command_error::user_error_with_message; @@ -61,6 +64,7 @@ pub enum GitCommand { Remote(RemoteCommand), #[command(subcommand, hide = true)] Submodule(GitSubmoduleCommand), + Sync(GitSyncArgs), } pub fn cmd_git( @@ -77,6 +81,7 @@ pub fn cmd_git( GitCommand::Push(args) => cmd_git_push(ui, command, args), GitCommand::Remote(args) => cmd_git_remote(ui, command, args), GitCommand::Submodule(args) => cmd_git_submodule(ui, command, args), + GitCommand::Sync(args) => cmd_git_sync(ui, command, args), } } diff --git a/cli/src/commands/git/sync.rs b/cli/src/commands/git/sync.rs new file mode 100644 index 00000000000..1a8a0036af5 --- /dev/null +++ b/cli/src/commands/git/sync.rs @@ -0,0 +1,332 @@ +// Copyright 2020-2023 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fmt; + +use clap_complete::ArgValueCandidates; +use itertools::Itertools; +use jj_lib::backend::CommitId; +use jj_lib::commit::Commit; +use jj_lib::op_store::RemoteRefState; +use jj_lib::repo::Repo; +use jj_lib::revset::FailingSymbolResolver; +use jj_lib::revset::RevsetExpression; +use jj_lib::revset::RevsetIteratorExt; +use jj_lib::rewrite::EmptyBehaviour; +use jj_lib::str_util::StringPattern; + +use crate::cli_util::short_commit_hash; +use crate::cli_util::CommandHelper; +use crate::cli_util::WorkspaceCommandTransaction; +use crate::command_error::CommandError; +use crate::complete; +use crate::git_util::get_fetch_remotes; +use crate::git_util::get_git_repo; +use crate::git_util::git_fetch; +use crate::git_util::FetchArgs; +use crate::ui::Ui; + +/// Sync the local `jj` repo to remote Git branch(es). +/// +/// The sync command will first fetch from the Git remote(s), then +/// rebase all local changes onto the appropriate updated +/// heads that were fetched. +/// +/// Changes that are made empty by the rebase are dropped. +#[derive(clap::Args, Clone, Debug)] +pub struct GitSyncArgs { + /// Rebase the specified branches only. + /// + /// Note that this affects only the rebase behaviour, as + /// the fetch behaviour always fetches all branches. + /// + /// By default, the specified name matches exactly. Use `glob:` prefix to + /// expand `*` as a glob. The other wildcard characters aren't supported. + #[arg(long, short, + alias="bookmark", + default_value = "glob:*", + value_parser = StringPattern::parse, + add = ArgValueCandidates::new(complete::bookmarks), + )] + pub branch: Vec, + /// Fetch from all remotes + /// + /// By default, the fetch will only use remotes configured in the + /// `git.fetch` section of the config. + /// + /// When specified, --all-remotes causes the fetch to use all remotes known + /// to the underlying git repo. + #[arg(long, default_value = "false")] + pub all_remotes: bool, +} + +pub fn cmd_git_sync( + ui: &mut Ui, + command: &CommandHelper, + args: &GitSyncArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let mut tx = workspace_command.start_transaction(); + + let guard = tracing::debug_span!("git.sync.pre-fetch").entered(); + let prefetch_heads = get_bookmark_heads(tx.base_repo().as_ref(), &args.branch)?; + let candidates = CandidateCommit::get(tx.repo(), &prefetch_heads)?; + drop(guard); + + let guard = tracing::debug_span!("git.sync.fetch").entered(); + git_fetch_all(ui, &mut tx, args.all_remotes)?; + drop(guard); + + let guard = tracing::debug_span!("git.sync.post-fetch").entered(); + let postfetch_heads = get_bookmark_heads(tx.repo(), &args.branch)?; + let update_record = UpdateRecord::new( + &tx, + &BranchHeads { + prefetch: &prefetch_heads, + postfetch: &postfetch_heads, + }, + ); + drop(guard); + + let guard = tracing::debug_span!("git.sync.rebase").entered(); + let settings = tx.settings().clone(); + let mut num_rebased = 0; + + tx.repo_mut().transform_descendants( + &settings, + update_record.get_rebase_roots(&candidates), + |mut rewriter| { + rewriter.simplify_ancestor_merge(); + let mut updated_parents: Vec = vec![]; + + let old_parents = rewriter.new_parents().iter().cloned().collect_vec(); + + let old_commit = short_commit_hash(rewriter.old_commit().id()); + for parent in &old_parents { + let old = short_commit_hash(parent); + if let Some(updated) = update_record.maybe_update_commit(rewriter.repo(), parent) { + let new = short_commit_hash(&updated); + tracing::debug!("rebase {old_commit} from {old} to {new}"); + updated_parents.push(updated.clone()); + } else { + tracing::debug!("not rebasing {old_commit} from {old}"); + updated_parents.push(parent.clone()); + } + } + + rewriter.set_new_parents(updated_parents); + + if let Some(builder) = + rewriter.rebase_with_empty_behavior(&settings, EmptyBehaviour::AbandonNewlyEmpty)? + { + builder.write()?; + num_rebased += 1; + } + + Ok(()) + }, + )?; + + tx.finish( + ui, + format!("sync completed; {num_rebased} commits rebased to new heads"), + )?; + + drop(guard); + + Ok(()) +} + +/// Returns a vector of commit ids corresponding to the target commit +/// of local bookmarks matching the supplied patterns. +fn get_bookmark_heads( + repo: &dyn Repo, + bookmarks: &[StringPattern], +) -> Result, CommandError> { + let mut commits: Vec = vec![]; + let local_bookmarks = bookmarks + .iter() + .flat_map(|pattern| { + repo.view() + .local_bookmarks_matching(pattern) + .map(|(name, _ref_target)| name) + .collect::>() + }) + .collect::>(); + + for bookmark in local_bookmarks { + tracing::debug!("fetching heads for bookmark {bookmark}"); + let bookmark_commits: Vec = + RevsetExpression::bookmarks(StringPattern::exact(bookmark.to_string())) + .resolve_user_expression(repo, &FailingSymbolResolver)? + .evaluate(repo)? + .iter() + .commits(repo.store()) + .try_collect()?; + + commits.append( + &mut bookmark_commits + .iter() + .map(|commit| commit.id().clone()) + .collect::>(), + ); + tracing::debug!("..Ok"); + } + + Ok(commits) +} + +fn set_diff(lhs: &[CommitId], rhs: &[CommitId]) -> Vec { + BTreeSet::from_iter(lhs.to_vec()) + .difference(&BTreeSet::from_iter(rhs.to_vec())) + .cloned() + .collect_vec() +} + +struct BranchHeads<'a> { + prefetch: &'a [CommitId], + postfetch: &'a [CommitId], +} + +struct UpdateRecord { + old_to_new: BTreeMap, +} + +impl UpdateRecord { + fn new(tx: &WorkspaceCommandTransaction, heads: &BranchHeads) -> Self { + let new_heads = set_diff(heads.postfetch, heads.prefetch); + let needs_rebase = set_diff(heads.prefetch, heads.postfetch); + + let mut old_to_new: BTreeMap = BTreeMap::from([]); + + for new in &new_heads { + for old in &needs_rebase { + if old != new && tx.repo().index().is_ancestor(old, new) { + old_to_new.insert(old.clone(), new.clone()); + } + } + } + + for (k, v) in &old_to_new { + let old = short_commit_hash(k); + let new = short_commit_hash(v); + tracing::debug!("rebase children of {old} to {new}"); + } + + UpdateRecord { old_to_new } + } + + /// Returns commits that need to be rebased. + /// + /// The returned commits all have parents in the `old_to_new` mapping, which + /// means that the branch their parents belong to, have advanced to new + /// commits. + fn get_rebase_roots(&self, candidates: &[CandidateCommit]) -> Vec { + candidates + .iter() + .filter_map(|candidate| { + if self.old_to_new.contains_key(&candidate.parent) { + Some(candidate.child.clone()) + } else { + None + } + }) + .collect_vec() + } + + fn maybe_update_commit(&self, repo: &dyn Repo, commit: &CommitId) -> Option { + self.old_to_new + .values() + .filter_map(|new| { + if new != commit && repo.index().is_ancestor(commit, new) { + Some(new.clone()) + } else { + None + } + }) + .next() + } +} + +#[derive(Eq, Ord, PartialEq, PartialOrd)] +pub struct CandidateCommit { + parent: CommitId, + child: CommitId, +} + +impl fmt::Display for CandidateCommit { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let parent = short_commit_hash(&self.parent); + let child = short_commit_hash(&self.child); + write!(f, "=> {parent} --> {child}") + } +} + +impl CandidateCommit { + fn get(repo: &dyn Repo, start: &[CommitId]) -> Result, CommandError> { + let commits: Vec = RevsetExpression::commits(start.to_vec()) + .descendants() + .minus(&RevsetExpression::remote_bookmarks( + StringPattern::everything(), + StringPattern::everything(), + Some(RemoteRefState::New), + )) + .resolve_user_expression(repo, &FailingSymbolResolver)? + .evaluate(repo)? + .iter() + .commits(repo.store()) + .try_collect()?; + + Ok(commits + .iter() + .flat_map(|commit| { + commit + .parent_ids() + .iter() + .map(|parent_id| { + let candidate = CandidateCommit { + parent: parent_id.clone(), + child: commit.id().clone(), + }; + tracing::debug!("candidate: {candidate}"); + candidate + }) + .collect::>() + }) + .collect::>()) + } +} + +fn git_fetch_all( + ui: &mut Ui, + tx: &mut WorkspaceCommandTransaction, + use_all_remotes: bool, +) -> Result<(), CommandError> { + let git_repo = get_git_repo(tx.base_repo().store())?; + let remotes = get_fetch_remotes(ui, tx.settings(), &git_repo, &[], use_all_remotes)?; + + tracing::debug!("fetching from remotes: {}", remotes.join(",")); + + git_fetch( + ui, + tx, + &git_repo, + &FetchArgs { + branch: &[StringPattern::everything()], + remotes: &remotes, + }, + ) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 76ba5f0d24e..223b8ccebc2 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -60,6 +60,7 @@ This document contains the help content for the `jj` command-line program. * [`jj git remote remove`↴](#jj-git-remote-remove) * [`jj git remote rename`↴](#jj-git-remote-rename) * [`jj git remote set-url`↴](#jj-git-remote-set-url) +* [`jj git sync`↴](#jj-git-sync) * [`jj help`↴](#jj-help) * [`jj init`↴](#jj-init) * [`jj interdiff`↴](#jj-interdiff) @@ -1050,6 +1051,7 @@ For a comparison with Git, including a table of commands, see https://martinvonz * `init` — Create a new Git backed repo * `push` — Push to a Git remote * `remote` — Manage Git remotes +* `sync` — Sync the local `jj` repo to remote Git branch(es) @@ -1264,6 +1266,35 @@ Set the URL of a Git remote +## `jj git sync` + +Sync the local `jj` repo to remote Git branch(es). + +The sync command will first fetch from the Git remote(s), then rebase all local changes onto the appropriate updated heads that were fetched. + +Changes that are made empty by the rebase are dropped. + +**Usage:** `jj git sync [OPTIONS]` + +###### **Options:** + +* `-b`, `--branch ` — Rebase the specified branches only. + + Note that this affects only the rebase behaviour, as the fetch behaviour always fetches all branches. + + By default, the specified name matches exactly. Use `glob:` prefix to expand `*` as a glob. The other wildcard characters aren't supported. + + Default value: `glob:*` +* `--all-remotes` — Fetch from all remotes + + By default, the fetch will only use remotes configured in the `git.fetch` section of the config. + + When specified, --all-remotes causes the fetch to use all remotes known to the underlying git repo. + + Default value: `false` + + + ## `jj help` Print this message or the help of the given subcommand(s) diff --git a/cli/tests/test_git_sync.rs b/cli/tests/test_git_sync.rs new file mode 100644 index 00000000000..19bb76d1f12 --- /dev/null +++ b/cli/tests/test_git_sync.rs @@ -0,0 +1,1382 @@ +// Copyright 2023 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +use std::path::Path; + +use crate::common::TestEnvironment; + +/// Creates a remote Git repo containing a bookmark with the same name +fn init_git_remote(test_env: &TestEnvironment, remote: &str) { + let git_repo_path = test_env.env_root().join(remote); + let git_repo = git2::Repository::init(git_repo_path).unwrap(); + let signature = + git2::Signature::new("Some One", "some.one@example.com", &git2::Time::new(0, 0)).unwrap(); + let mut tree_builder = git_repo.treebuilder(None).unwrap(); + let file_oid = git_repo.blob(remote.as_bytes()).unwrap(); + tree_builder + .insert("file", file_oid, git2::FileMode::Blob.into()) + .unwrap(); + let tree_oid = tree_builder.write().unwrap(); + let tree = git_repo.find_tree(tree_oid).unwrap(); + git_repo + .commit( + Some(&format!("refs/heads/{remote}")), + &signature, + &signature, + "message", + &tree, + &[], + ) + .unwrap(); +} + +/// Add a remote containing a bookmark with the same name +fn add_git_remote(test_env: &TestEnvironment, repo_path: &Path, remote: &str) { + init_git_remote(test_env, remote); + test_env.jj_cmd_ok( + repo_path, + &["git", "remote", "add", remote, &format!("../{remote}")], + ); +} + +fn get_bookmark_output(test_env: &TestEnvironment, repo_path: &Path) -> String { + // --quiet to suppress deleted bookmarks hint + test_env.jj_cmd_success(repo_path, &["bookmark", "list", "--all-remotes", "--quiet"]) +} + +fn create_commit(test_env: &TestEnvironment, repo_path: &Path, name: &str, parents: &[&str]) { + let descr = format!("descr_for_{name}"); + if parents.is_empty() { + test_env.jj_cmd_ok(repo_path, &["new", "root()", "-m", &descr]); + } else { + let mut args = vec!["new", "-m", &descr]; + args.extend(parents); + test_env.jj_cmd_ok(repo_path, &args); + } + std::fs::write(repo_path.join(name), format!("{name}\n")).unwrap(); + test_env.jj_cmd_ok(repo_path, &["bookmark", "create", name]); +} + +fn get_log_output(test_env: &TestEnvironment, workspace_root: &Path) -> String { + let template = r#"commit_id.short() ++ " " ++ description.first_line() ++ " " ++ bookmarks"#; + test_env.jj_cmd_success(workspace_root, &["log", "-T", template, "-r", "all()"]) +} + +#[test] +fn test_git_fetch_with_default_config() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "origin"); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin@origin: oputwtnw ffecd2d6 message + "###); +} + +#[test] +fn test_git_fetch_default_remote() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "origin"); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin: oputwtnw ffecd2d6 message + @origin: oputwtnw ffecd2d6 message + "###); +} + +#[test] +fn test_git_fetch_single_remote() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(stderr, @r###" + Hint: Fetching from the only existing remote: rem1 + bookmark: rem1@rem1 [new] tracked + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + "###); +} + +#[test] +fn test_git_fetch_single_remote_all_remotes_flag() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + + test_env + .jj_cmd(&repo_path, &["git", "fetch", "--all-remotes"]) + .assert() + .success(); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + "###); +} + +#[test] +fn test_git_fetch_single_remote_from_arg() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--remote", "rem1"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + "###); +} + +#[test] +fn test_git_fetch_single_remote_from_config() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + test_env.add_config(r#"git.fetch = "rem1""#); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + "###); +} + +#[test] +fn test_git_fetch_multiple_remotes() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + add_git_remote(&test_env, &repo_path, "rem2"); + + test_env.jj_cmd_ok( + &repo_path, + &["git", "fetch", "--remote", "rem1", "--remote", "rem2"], + ); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + rem2: yszkquru 2497a8a0 message + @rem2: yszkquru 2497a8a0 message + "###); +} + +#[test] +fn test_git_fetch_all_remotes() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + add_git_remote(&test_env, &repo_path, "rem2"); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--all-remotes"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + rem2: yszkquru 2497a8a0 message + @rem2: yszkquru 2497a8a0 message + "###); +} + +#[test] +fn test_git_fetch_multiple_remotes_from_config() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + add_git_remote(&test_env, &repo_path, "rem2"); + test_env.add_config(r#"git.fetch = ["rem1", "rem2"]"#); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + rem2: yszkquru 2497a8a0 message + @rem2: yszkquru 2497a8a0 message + "###); +} + +#[test] +fn test_git_fetch_nonexistent_remote() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + + let stderr = &test_env.jj_cmd_failure( + &repo_path, + &["git", "fetch", "--remote", "rem1", "--remote", "rem2"], + ); + insta::assert_snapshot!(stderr, @r###" + bookmark: rem1@rem1 [new] untracked + Error: No git remote named 'rem2' + "###); + // No remote should have been fetched as part of the failing transaction + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @""); +} + +#[test] +fn test_git_fetch_nonexistent_remote_from_config() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + test_env.add_config(r#"git.fetch = ["rem1", "rem2"]"#); + + let stderr = &test_env.jj_cmd_failure(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(stderr, @r###" + bookmark: rem1@rem1 [new] untracked + Error: No git remote named 'rem2' + "###); + // No remote should have been fetched as part of the failing transaction + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @""); +} + +#[test] +fn test_git_fetch_from_remote_named_git() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + let repo_path = test_env.env_root().join("repo"); + init_git_remote(&test_env, "git"); + let git_repo = git2::Repository::init(&repo_path).unwrap(); + git_repo.remote("git", "../git").unwrap(); + + // Existing remote named 'git' shouldn't block the repo initialization. + test_env.jj_cmd_ok(&repo_path, &["git", "init", "--git-repo=."]); + + // Try fetching from the remote named 'git'. + let stderr = &test_env.jj_cmd_failure(&repo_path, &["git", "fetch", "--remote=git"]); + insta::assert_snapshot!(stderr, @r###" + Error: Failed to import refs from underlying Git repo + Caused by: Git remote named 'git' is reserved for local Git repository + Hint: Run `jj git remote rename` to give different name. + "###); + + // Implicit import shouldn't fail because of the remote ref. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["bookmark", "list", "--all-remotes"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @""); + + // Explicit import is an error. + // (This could be warning if we add mechanism to report ignored refs.) + insta::assert_snapshot!(test_env.jj_cmd_failure(&repo_path, &["git", "import"]), @r###" + Error: Failed to import refs from underlying Git repo + Caused by: Git remote named 'git' is reserved for local Git repository + Hint: Run `jj git remote rename` to give different name. + "###); + + // The remote can be renamed, and the ref can be imported. + test_env.jj_cmd_ok(&repo_path, &["git", "remote", "rename", "git", "bar"]); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["bookmark", "list", "--all-remotes"]); + insta::assert_snapshot!(stdout, @r###" + git: mrylzrtu 76fc7466 message + @bar: mrylzrtu 76fc7466 message + @git: mrylzrtu 76fc7466 message + "###); + insta::assert_snapshot!(stderr, @r###" + Done importing changes from the underlying Git repo. + "###); +} + +#[test] +fn test_git_fetch_prune_before_updating_tips() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "origin"); + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin: oputwtnw ffecd2d6 message + @origin: oputwtnw ffecd2d6 message + "###); + + // Remove origin bookmark in git repo and create origin/subname + let git_repo = git2::Repository::open(test_env.env_root().join("origin")).unwrap(); + git_repo + .find_branch("origin", git2::BranchType::Local) + .unwrap() + .rename("origin/subname", false) + .unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin/subname: oputwtnw ffecd2d6 message + @origin: oputwtnw ffecd2d6 message + "###); +} + +#[test] +fn test_git_fetch_conflicting_bookmarks() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "rem1"); + + // Create a rem1 bookmark locally + test_env.jj_cmd_ok(&repo_path, &["new", "root()"]); + test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "rem1"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: kkmpptxz fcdbbd73 (empty) (no description set) + "###); + + test_env.jj_cmd_ok( + &repo_path, + &["git", "fetch", "--remote", "rem1", "--branch", "glob:*"], + ); + // This should result in a CONFLICTED bookmark + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1 (conflicted): + + kkmpptxz fcdbbd73 (empty) (no description set) + + qxosxrvv 6a211027 message + @rem1 (behind by 1 commits): qxosxrvv 6a211027 message + "###); +} + +#[test] +fn test_git_fetch_conflicting_bookmarks_colocated() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + let repo_path = test_env.env_root().join("repo"); + let _git_repo = git2::Repository::init(&repo_path).unwrap(); + // create_colocated_repo_and_bookmarks_from_trunk1(&test_env, &repo_path); + test_env.jj_cmd_ok(&repo_path, &["git", "init", "--git-repo", "."]); + add_git_remote(&test_env, &repo_path, "rem1"); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @""); + + // Create a rem1 bookmark locally + test_env.jj_cmd_ok(&repo_path, &["new", "root()"]); + test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "rem1"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1: zsuskuln f652c321 (empty) (no description set) + @git: zsuskuln f652c321 (empty) (no description set) + "###); + + test_env.jj_cmd_ok( + &repo_path, + &["git", "fetch", "--remote", "rem1", "--branch", "rem1"], + ); + // This should result in a CONFLICTED bookmark + // See https://github.com/martinvonz/jj/pull/1146#discussion_r1112372340 for the bug this tests for. + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + rem1 (conflicted): + + zsuskuln f652c321 (empty) (no description set) + + qxosxrvv 6a211027 message + @git (behind by 1 commits): zsuskuln f652c321 (empty) (no description set) + @rem1 (behind by 1 commits): qxosxrvv 6a211027 message + "###); +} + +// Helper functions to test obtaining multiple bookmarks at once and changed +// bookmarks +fn create_colocated_repo_and_bookmarks_from_trunk1( + test_env: &TestEnvironment, + repo_path: &Path, +) -> String { + // Create a colocated repo in `source` to populate it more easily + test_env.jj_cmd_ok(repo_path, &["git", "init", "--git-repo", "."]); + create_commit(test_env, repo_path, "trunk1", &[]); + create_commit(test_env, repo_path, "a1", &["trunk1"]); + create_commit(test_env, repo_path, "a2", &["trunk1"]); + create_commit(test_env, repo_path, "b", &["trunk1"]); + format!( + " ===== Source git repo contents =====\n{}", + get_log_output(test_env, repo_path) + ) +} + +fn create_trunk2_and_rebase_bookmarks(test_env: &TestEnvironment, repo_path: &Path) -> String { + create_commit(test_env, repo_path, "trunk2", &["trunk1"]); + for br in ["a1", "a2", "b"] { + test_env.jj_cmd_ok(repo_path, &["rebase", "-b", br, "-d", "trunk2"]); + } + format!( + " ===== Source git repo contents =====\n{}", + get_log_output(test_env, repo_path) + ) +} + +#[test] +fn test_git_fetch_all() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.add_config(r#"revset-aliases."immutable_heads()" = "none()""#); + let source_git_repo_path = test_env.env_root().join("source"); + let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap(); + + // Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated + let (stdout, stderr) = + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "source", "target"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Fetching into new repo in "$TEST_ENV/target" + Nothing changed. + "###); + let target_jj_repo_path = test_env.env_root().join("target"); + + let source_log = + create_colocated_repo_and_bookmarks_from_trunk1(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + @ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + + // Nothing in our repo before the fetch + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + @ 230dd059e1b0 + ◆ 000000000000 + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &target_jj_repo_path), @""); + let (stdout, stderr) = test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [new] tracked + bookmark: a2@origin [new] tracked + bookmark: b@origin [new] tracked + bookmark: trunk1@origin [new] tracked + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &target_jj_repo_path), @r###" + a1: nknoxmzm 359a9a02 descr_for_a1 + @origin: nknoxmzm 359a9a02 descr_for_a1 + a2: qkvnknrk decaa396 descr_for_a2 + @origin: qkvnknrk decaa396 descr_for_a2 + b: vpupmnsl c7d4bdcb descr_for_b + @origin: vpupmnsl c7d4bdcb descr_for_b + trunk1: zowqyktl ff36dc55 descr_for_trunk1 + @origin: zowqyktl ff36dc55 descr_for_trunk1 + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + + // ==== Change both repos ==== + // First, change the target repo: + let source_log = create_trunk2_and_rebase_bookmarks(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + ○ babc49226c14 descr_for_b b + │ ○ 91e46b4b2653 descr_for_a2 a2 + ├─╯ + │ ○ 0424f6dfc1ff descr_for_a1 a1 + ├─╯ + @ 8f1f14fbbf42 descr_for_trunk2 trunk2 + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + // Change a bookmark in the source repo as well, so that it becomes conflicted. + test_env.jj_cmd_ok( + &target_jj_repo_path, + &["describe", "b", "-m=new_descr_for_b_to_create_conflict"], + ); + + // Our repo before and after fetch + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ 061eddbb43ab new_descr_for_b_to_create_conflict b* + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &target_jj_repo_path), @r###" + a1: nknoxmzm 359a9a02 descr_for_a1 + @origin: nknoxmzm 359a9a02 descr_for_a1 + a2: qkvnknrk decaa396 descr_for_a2 + @origin: qkvnknrk decaa396 descr_for_a2 + b: vpupmnsl 061eddbb new_descr_for_b_to_create_conflict + @origin (ahead by 1 commits, behind by 1 commits): vpupmnsl hidden c7d4bdcb descr_for_b + trunk1: zowqyktl ff36dc55 descr_for_trunk1 + @origin: zowqyktl ff36dc55 descr_for_trunk1 + "###); + let (stdout, stderr) = test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [updated] tracked + bookmark: a2@origin [updated] tracked + bookmark: b@origin [updated] tracked + bookmark: trunk2@origin [new] tracked + Abandoned 2 commits that are no longer reachable. + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &target_jj_repo_path), @r###" + a1: quxllqov 0424f6df descr_for_a1 + @origin: quxllqov 0424f6df descr_for_a1 + a2: osusxwst 91e46b4b descr_for_a2 + @origin: osusxwst 91e46b4b descr_for_a2 + b (conflicted): + - vpupmnsl hidden c7d4bdcb descr_for_b + + vpupmnsl 061eddbb new_descr_for_b_to_create_conflict + + vktnwlsu babc4922 descr_for_b + @origin (behind by 1 commits): vktnwlsu babc4922 descr_for_b + trunk1: zowqyktl ff36dc55 descr_for_trunk1 + @origin: zowqyktl ff36dc55 descr_for_trunk1 + trunk2: umznmzko 8f1f14fb descr_for_trunk2 + @origin: umznmzko 8f1f14fb descr_for_trunk2 + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ babc49226c14 descr_for_b b?? b@origin + │ ○ 91e46b4b2653 descr_for_a2 a2 + ├─╯ + │ ○ 0424f6dfc1ff descr_for_a1 a1 + ├─╯ + ○ 8f1f14fbbf42 descr_for_trunk2 trunk2 + │ ○ 061eddbb43ab new_descr_for_b_to_create_conflict b?? + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); +} + +#[test] +fn test_git_fetch_some_of_many_bookmarks() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.add_config(r#"revset-aliases."immutable_heads()" = "none()""#); + let source_git_repo_path = test_env.env_root().join("source"); + let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap(); + + // Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated + let (stdout, stderr) = + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "source", "target"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Fetching into new repo in "$TEST_ENV/target" + Nothing changed. + "###); + let target_jj_repo_path = test_env.env_root().join("target"); + + let source_log = + create_colocated_repo_and_bookmarks_from_trunk1(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + @ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + + // Test an error message + let stderr = test_env.jj_cmd_failure( + &target_jj_repo_path, + &["git", "fetch", "--branch", "glob:^:a*"], + ); + insta::assert_snapshot!(stderr, @r###" + Error: Invalid branch pattern provided. Patterns may not contain the characters `:`, `^`, `?`, `[`, `]` + "###); + let stderr = test_env.jj_cmd_failure(&target_jj_repo_path, &["git", "fetch", "--branch", "a*"]); + insta::assert_snapshot!(stderr, @r###" + Error: Invalid branch pattern provided. Patterns may not contain the characters `:`, `^`, `?`, `[`, `]` + Hint: Prefix the pattern with `glob:` to expand `*` as a glob + "###); + + // Nothing in our repo before the fetch + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + @ 230dd059e1b0 + ◆ 000000000000 + "###); + // Fetch one bookmark... + let (stdout, stderr) = + test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch", "--branch", "b"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: b@origin [new] tracked + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + // ...check what the intermediate state looks like... + insta::assert_snapshot!(get_bookmark_output(&test_env, &target_jj_repo_path), @r###" + b: vpupmnsl c7d4bdcb descr_for_b + @origin: vpupmnsl c7d4bdcb descr_for_b + "###); + // ...then fetch two others with a glob. + let (stdout, stderr) = test_env.jj_cmd_ok( + &target_jj_repo_path, + &["git", "fetch", "--branch", "glob:a*"], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [new] tracked + bookmark: a2@origin [new] tracked + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ decaa3966c83 descr_for_a2 a2 + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + │ ○ c7d4bdcbc215 descr_for_b b + ├─╯ + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + // Fetching the same bookmark again + let (stdout, stderr) = + test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch", "--branch", "a1"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Nothing changed. + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ decaa3966c83 descr_for_a2 a2 + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + │ ○ c7d4bdcbc215 descr_for_b b + ├─╯ + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + + // ==== Change both repos ==== + // First, change the target repo: + let source_log = create_trunk2_and_rebase_bookmarks(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + ○ 01d115196c39 descr_for_b b + │ ○ 31c7d94b1f29 descr_for_a2 a2 + ├─╯ + │ ○ 6df2d34cf0da descr_for_a1 a1 + ├─╯ + @ 2bb3ebd2bba3 descr_for_trunk2 trunk2 + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + // Change a bookmark in the source repo as well, so that it becomes conflicted. + test_env.jj_cmd_ok( + &target_jj_repo_path, + &["describe", "b", "-m=new_descr_for_b_to_create_conflict"], + ); + + // Our repo before and after fetch of two bookmarks + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ 6ebd41dc4f13 new_descr_for_b_to_create_conflict b* + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + let (stdout, stderr) = test_env.jj_cmd_ok( + &target_jj_repo_path, + &["git", "fetch", "--branch", "b", "--branch", "a1"], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [updated] tracked + bookmark: b@origin [updated] tracked + Abandoned 1 commits that are no longer reachable. + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ 01d115196c39 descr_for_b b?? b@origin + │ ○ 6df2d34cf0da descr_for_a1 a1 + ├─╯ + ○ 2bb3ebd2bba3 descr_for_trunk2 + │ ○ 6ebd41dc4f13 new_descr_for_b_to_create_conflict b?? + ├─╯ + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + + // We left a2 where it was before, let's see how `jj bookmark list` sees this. + insta::assert_snapshot!(get_bookmark_output(&test_env, &target_jj_repo_path), @r###" + a1: ypowunwp 6df2d34c descr_for_a1 + @origin: ypowunwp 6df2d34c descr_for_a1 + a2: qkvnknrk decaa396 descr_for_a2 + @origin: qkvnknrk decaa396 descr_for_a2 + b (conflicted): + - vpupmnsl hidden c7d4bdcb descr_for_b + + vpupmnsl 6ebd41dc new_descr_for_b_to_create_conflict + + nxrpswuq 01d11519 descr_for_b + @origin (behind by 1 commits): nxrpswuq 01d11519 descr_for_b + "###); + // Now, let's fetch a2 and double-check that fetching a1 and b again doesn't do + // anything. + let (stdout, stderr) = test_env.jj_cmd_ok( + &target_jj_repo_path, + &["git", "fetch", "--branch", "b", "--branch", "glob:a*"], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a2@origin [updated] tracked + Abandoned 1 commits that are no longer reachable. + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ 31c7d94b1f29 descr_for_a2 a2 + │ ○ 01d115196c39 descr_for_b b?? b@origin + ├─╯ + │ ○ 6df2d34cf0da descr_for_a1 a1 + ├─╯ + ○ 2bb3ebd2bba3 descr_for_trunk2 + │ ○ 6ebd41dc4f13 new_descr_for_b_to_create_conflict b?? + ├─╯ + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &target_jj_repo_path), @r###" + a1: ypowunwp 6df2d34c descr_for_a1 + @origin: ypowunwp 6df2d34c descr_for_a1 + a2: qrmzolkr 31c7d94b descr_for_a2 + @origin: qrmzolkr 31c7d94b descr_for_a2 + b (conflicted): + - vpupmnsl hidden c7d4bdcb descr_for_b + + vpupmnsl 6ebd41dc new_descr_for_b_to_create_conflict + + nxrpswuq 01d11519 descr_for_b + @origin (behind by 1 commits): nxrpswuq 01d11519 descr_for_b + "###); +} + +#[test] +fn test_git_fetch_bookmarks_some_missing() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "origin"); + add_git_remote(&test_env, &repo_path, "rem1"); + add_git_remote(&test_env, &repo_path, "rem2"); + + // single missing bookmark, implicit remotes (@origin) + let (_stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--branch", "noexist"]); + insta::assert_snapshot!(stderr, @r###" + Warning: No branch matching `noexist` found on any specified/configured remote + Nothing changed. + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @""); + + // multiple missing bookmarks, implicit remotes (@origin) + let (_stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &[ + "git", "fetch", "--branch", "noexist1", "--branch", "noexist2", + ], + ); + insta::assert_snapshot!(stderr, @r###" + Warning: No branch matching `noexist1` found on any specified/configured remote + Warning: No branch matching `noexist2` found on any specified/configured remote + Nothing changed. + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @""); + + // single existing bookmark, implicit remotes (@origin) + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--branch", "origin"]); + insta::assert_snapshot!(stderr, @r###" + bookmark: origin@origin [new] tracked + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin: oputwtnw ffecd2d6 message + @origin: oputwtnw ffecd2d6 message + "###); + + // multiple existing bookmark, explicit remotes, each bookmark is only in one + // remote. + let (_stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &[ + "git", "fetch", "--branch", "rem1", "--branch", "rem2", "--remote", "rem1", "--remote", + "rem2", + ], + ); + insta::assert_snapshot!(stderr, @r###" + bookmark: rem1@rem1 [new] tracked + bookmark: rem2@rem2 [new] tracked + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin: oputwtnw ffecd2d6 message + @origin: oputwtnw ffecd2d6 message + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + rem2: yszkquru 2497a8a0 message + @rem2: yszkquru 2497a8a0 message + "###); + + // multiple bookmarks, one exists, one doesn't + let (_stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &[ + "git", "fetch", "--branch", "rem1", "--branch", "notexist", "--remote", "rem1", + ], + ); + insta::assert_snapshot!(stderr, @r###" + Warning: No branch matching `notexist` found on any specified/configured remote + Nothing changed. + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin: oputwtnw ffecd2d6 message + @origin: oputwtnw ffecd2d6 message + rem1: qxosxrvv 6a211027 message + @rem1: qxosxrvv 6a211027 message + rem2: yszkquru 2497a8a0 message + @rem2: yszkquru 2497a8a0 message + "###); +} + +// See `test_undo_restore_commands.rs` for fetch-undo-push and fetch-undo-fetch +// of the same bookmarks for various kinds of undo. +#[test] +fn test_git_fetch_undo() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + let source_git_repo_path = test_env.env_root().join("source"); + let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap(); + + // Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated + let (stdout, stderr) = + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "source", "target"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Fetching into new repo in "$TEST_ENV/target" + Nothing changed. + "###); + let target_jj_repo_path = test_env.env_root().join("target"); + + let source_log = + create_colocated_repo_and_bookmarks_from_trunk1(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + @ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + + // Fetch 2 bookmarks + let (stdout, stderr) = test_env.jj_cmd_ok( + &target_jj_repo_path, + &["git", "fetch", "--branch", "b", "--branch", "a1"], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [new] tracked + bookmark: b@origin [new] tracked + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + let (stdout, stderr) = test_env.jj_cmd_ok(&target_jj_repo_path, &["undo"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Undid operation: eb2029853b02 (2001-02-03 08:05:18) fetch from git remote(s) origin + "#); + // The undo works as expected + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + @ 230dd059e1b0 + ◆ 000000000000 + "###); + // Now try to fetch just one bookmark + let (stdout, stderr) = + test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch", "--branch", "b"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: b@origin [new] tracked + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); +} + +// Compare to `test_git_import_undo` in test_git_import_export +// TODO: Explain why these behaviors are useful +#[test] +fn test_fetch_undo_what() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + let source_git_repo_path = test_env.env_root().join("source"); + let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap(); + + // Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated + let (stdout, stderr) = + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "source", "target"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Fetching into new repo in "$TEST_ENV/target" + Nothing changed. + "###); + let repo_path = test_env.env_root().join("target"); + + let source_log = + create_colocated_repo_and_bookmarks_from_trunk1(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + @ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + + // Initial state we will try to return to after `op restore`. There are no + // bookmarks. + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @""); + let base_operation_id = test_env.current_operation_id(&repo_path); + + // Fetch a bookmark + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--branch", "b"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: b@origin [new] tracked + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + b: vpupmnsl c7d4bdcb descr_for_b + @origin: vpupmnsl c7d4bdcb descr_for_b + "###); + + // We can undo the change in the repo without moving the remote-tracking + // bookmark + let (stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &["op", "restore", "--what", "repo", &base_operation_id], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Restored to operation: eac759b9ab75 (2001-02-03 08:05:07) add workspace 'default' + "#); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + b (deleted) + @origin: vpupmnsl hidden c7d4bdcb descr_for_b + "###); + + // Now, let's demo restoring just the remote-tracking bookmark. First, let's + // change our local repo state... + test_env.jj_cmd_ok(&repo_path, &["bookmark", "c", "newbookmark"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + b (deleted) + @origin: vpupmnsl hidden c7d4bdcb descr_for_b + newbookmark: qpvuntsm 230dd059 (empty) (no description set) + "###); + // Restoring just the remote-tracking state will not affect `newbookmark`, but + // will eliminate `b@origin`. + let (stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &[ + "op", + "restore", + "--what", + "remote-tracking", + &base_operation_id, + ], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r#" + Restored to operation: eac759b9ab75 (2001-02-03 08:05:07) add workspace 'default' + "#); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + newbookmark: qpvuntsm 230dd059 (empty) (no description set) + "###); +} + +#[test] +fn test_git_fetch_remove_fetch() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "origin"); + + test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "origin"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin: qpvuntsm 230dd059 (empty) (no description set) + "###); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin (conflicted): + + qpvuntsm 230dd059 (empty) (no description set) + + oputwtnw ffecd2d6 message + @origin (behind by 1 commits): oputwtnw ffecd2d6 message + "###); + + test_env.jj_cmd_ok(&repo_path, &["git", "remote", "remove", "origin"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin (conflicted): + + qpvuntsm 230dd059 (empty) (no description set) + + oputwtnw ffecd2d6 message + "###); + + test_env.jj_cmd_ok(&repo_path, &["git", "remote", "add", "origin", "../origin"]); + + // Check that origin@origin is properly recreated + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: origin@origin [new] tracked + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin (conflicted): + + qpvuntsm 230dd059 (empty) (no description set) + + oputwtnw ffecd2d6 message + @origin (behind by 1 commits): oputwtnw ffecd2d6 message + "###); +} + +#[test] +fn test_git_fetch_rename_fetch() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + add_git_remote(&test_env, &repo_path, "origin"); + + test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "origin"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin: qpvuntsm 230dd059 (empty) (no description set) + "###); + + test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin (conflicted): + + qpvuntsm 230dd059 (empty) (no description set) + + oputwtnw ffecd2d6 message + @origin (behind by 1 commits): oputwtnw ffecd2d6 message + "###); + + test_env.jj_cmd_ok( + &repo_path, + &["git", "remote", "rename", "origin", "upstream"], + ); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + origin (conflicted): + + qpvuntsm 230dd059 (empty) (no description set) + + oputwtnw ffecd2d6 message + @upstream (behind by 1 commits): oputwtnw ffecd2d6 message + "###); + + // Check that jj indicates that nothing has changed + let (stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--remote", "upstream"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Nothing changed. + "###); +} + +#[test] +fn test_git_fetch_removed_bookmark() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + let source_git_repo_path = test_env.env_root().join("source"); + let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap(); + + // Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated + let (stdout, stderr) = + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "source", "target"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Fetching into new repo in "$TEST_ENV/target" + Nothing changed. + "###); + let target_jj_repo_path = test_env.env_root().join("target"); + + let source_log = + create_colocated_repo_and_bookmarks_from_trunk1(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + @ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + + // Fetch all bookmarks + let (stdout, stderr) = test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [new] tracked + bookmark: a2@origin [new] tracked + bookmark: b@origin [new] tracked + bookmark: trunk1@origin [new] tracked + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + + // Remove a2 bookmark in origin + test_env.jj_cmd_ok(&source_git_repo_path, &["bookmark", "forget", "a2"]); + + // Fetch bookmark a1 from origin and check that a2 is still there + let (stdout, stderr) = + test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch", "--branch", "a1"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Nothing changed. + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + + // Fetch bookmarks a2 from origin, and check that it has been removed locally + let (stdout, stderr) = + test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch", "--branch", "a2"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a2@origin [deleted] untracked + Abandoned 1 commits that are no longer reachable. + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); +} + +#[test] +fn test_git_fetch_removed_parent_bookmark() { + let test_env = TestEnvironment::default(); + test_env.add_config("git.auto-local-bookmark = true"); + let source_git_repo_path = test_env.env_root().join("source"); + let _git_repo = git2::Repository::init(source_git_repo_path.clone()).unwrap(); + + // Clone an empty repo. The target repo is a normal `jj` repo, *not* colocated + let (stdout, stderr) = + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "source", "target"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Fetching into new repo in "$TEST_ENV/target" + Nothing changed. + "###); + let target_jj_repo_path = test_env.env_root().join("target"); + + let source_log = + create_colocated_repo_and_bookmarks_from_trunk1(&test_env, &source_git_repo_path); + insta::assert_snapshot!(source_log, @r###" + ===== Source git repo contents ===== + @ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + ◆ 000000000000 + "###); + + // Fetch all bookmarks + let (stdout, stderr) = test_env.jj_cmd_ok(&target_jj_repo_path, &["git", "fetch"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [new] tracked + bookmark: a2@origin [new] tracked + bookmark: b@origin [new] tracked + bookmark: trunk1@origin [new] tracked + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + │ ○ 359a9a02457d descr_for_a1 a1 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + + // Remove all bookmarks in origin. + test_env.jj_cmd_ok(&source_git_repo_path, &["bookmark", "forget", "glob:*"]); + + // Fetch bookmarks master, trunk1 and a1 from origin and check that only those + // bookmarks have been removed and that others were not rebased because of + // abandoned commits. + let (stdout, stderr) = test_env.jj_cmd_ok( + &target_jj_repo_path, + &[ + "git", "fetch", "--branch", "master", "--branch", "trunk1", "--branch", "a1", + ], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + bookmark: a1@origin [deleted] untracked + bookmark: trunk1@origin [deleted] untracked + Abandoned 1 commits that are no longer reachable. + Warning: No branch matching `master` found on any specified/configured remote + "###); + insta::assert_snapshot!(get_log_output(&test_env, &target_jj_repo_path), @r###" + ○ c7d4bdcbc215 descr_for_b b + │ ○ decaa3966c83 descr_for_a2 a2 + ├─╯ + ○ ff36dc55760e descr_for_trunk1 + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); +} + +#[test] +fn test_git_fetch_remote_only_bookmark() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + // Create non-empty git repo to add as a remote + let git_repo_path = test_env.env_root().join("git-repo"); + let git_repo = git2::Repository::init(git_repo_path).unwrap(); + let signature = + git2::Signature::new("Some One", "some.one@example.com", &git2::Time::new(0, 0)).unwrap(); + let mut tree_builder = git_repo.treebuilder(None).unwrap(); + let file_oid = git_repo.blob(b"content").unwrap(); + tree_builder + .insert("file", file_oid, git2::FileMode::Blob.into()) + .unwrap(); + let tree_oid = tree_builder.write().unwrap(); + let tree = git_repo.find_tree(tree_oid).unwrap(); + test_env.jj_cmd_ok( + &repo_path, + &["git", "remote", "add", "origin", "../git-repo"], + ); + // Create a commit and a bookmark in the git repo + git_repo + .commit( + Some("refs/heads/feature1"), + &signature, + &signature, + "message", + &tree, + &[], + ) + .unwrap(); + + // Fetch using git.auto_local_bookmark = true + test_env.add_config("git.auto-local-bookmark = true"); + test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--remote=origin"]); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + feature1: mzyxwzks 9f01a0e0 message + @origin: mzyxwzks 9f01a0e0 message + "###); + + git_repo + .commit( + Some("refs/heads/feature2"), + &signature, + &signature, + "message", + &tree, + &[], + ) + .unwrap(); + + // Fetch using git.auto_local_bookmark = false + test_env.add_config("git.auto-local-bookmark = false"); + test_env.jj_cmd_ok(&repo_path, &["git", "fetch", "--remote=origin"]); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + ◆ 9f01a0e04879 message feature1 feature2@origin + │ @ 230dd059e1b0 + ├─╯ + ◆ 000000000000 + "###); + insta::assert_snapshot!(get_bookmark_output(&test_env, &repo_path), @r###" + feature1: mzyxwzks 9f01a0e0 message + @origin: mzyxwzks 9f01a0e0 message + feature2@origin: mzyxwzks 9f01a0e0 message + "###); +} diff --git a/lib/src/rewrite.rs b/lib/src/rewrite.rs index 773491a2ffe..b281e8aca3a 100644 --- a/lib/src/rewrite.rs +++ b/lib/src/rewrite.rs @@ -155,6 +155,11 @@ impl<'repo> CommitRewriter<'repo> { self.mut_repo } + /// Returns an immutable reference to `MutableRepo`. + pub fn repo(&mut self) -> &MutableRepo { + self.mut_repo + } + /// The commit we're rewriting. pub fn old_commit(&self) -> &Commit { &self.old_commit