diff --git a/CHANGELOG.md b/CHANGELOG.md index 920ca1ede6..2213cb76d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed bugs +* Previously, `jj git push` only made sure that the branch is in the expected + location on the remote server when pushing a branch forward (as opposed to + sideways or backwards). Now, `jj git push` makes a safety check in all cases + and fails whenever `jj git fetch` would have introduced a conflict. + + In other words, previously branches that moved sideways or backward were + pushed similarly to Git's `git push --force`; now they have protections + similar to `git push --force-with-lease` (though not identical to it, to match + the behavior of `jj git fetch`). Note also that because of the way `jj git + fetch` works, `jj` does not suffer from the same problems as Git's `git push + --force-with-lease` in situations when `git fetch` is run in the background. + * When the working copy commit becomes immutable, a new one is automatically created on top of it to avoid letting the user edit the immutable one. diff --git a/cli/src/commands/git.rs b/cli/src/commands/git.rs index 2e1cbe86e7..994868fbf6 100644 --- a/cli/src/commands/git.rs +++ b/cli/src/commands/git.rs @@ -899,6 +899,10 @@ fn cmd_git_push( } let mut new_heads = vec![]; + // Short-term TODO: `force_pushed_branches` no longer has an effect on how + // branches are actually pushed. Messages about "Moving" vs "Forcing" + // branches are now misleading. Change this logic so that we print whether + // the branch moved forward, backward, or sideways. let mut force_pushed_branches = hashset! {}; for (branch_name, update) in &branch_updates { if let Some(new_target) = &update.new_target { @@ -1001,10 +1005,7 @@ fn cmd_git_push( return Ok(()); } - let targets = GitBranchPushTargets { - branch_updates, - force_pushed_branches, - }; + let targets = GitBranchPushTargets { branch_updates }; let mut writer = GitSidebandProgressMessageWriter::new(ui); let mut sideband_progress_callback = |progress_message: &[u8]| { _ = writer.write(ui, progress_message); @@ -1014,8 +1015,12 @@ fn cmd_git_push( }) .map_err(|err| match err { GitPushError::InternalGitError(err) => map_git_error(err), - GitPushError::NotFastForward => user_error_with_hint( - "The push conflicts with changes made on the remote (it is not fast-forwardable).", + GitPushError::RefInUnexpectedLocation(refs) => user_error_with_hint( + format!( + "Refusing to push a branch that unexpectedly moved on the remote. Affected refs: \ + {}", + refs.join(", ") + ), "Try fetching from the remote, then make the branch point to where you want it to be, \ and push again.", ), diff --git a/cli/tests/test_git_push.rs b/cli/tests/test_git_push.rs index 5651fb0606..26191476cd 100644 --- a/cli/tests/test_git_push.rs +++ b/cli/tests/test_git_push.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; -use crate::common::{get_stderr_string, get_stdout_string, TestEnvironment}; +use crate::common::TestEnvironment; fn set_up() -> (TestEnvironment, PathBuf) { let test_env = TestEnvironment::default(); @@ -196,7 +196,14 @@ fn test_git_push_other_remote_has_branch() { Warning: No branches found in the default push revset: remote_branches(remote=origin)..@ Nothing changed. "###); - // But it will still get pushed to another remote + // The branch was moved on the "other" remote as well (since it's actually the + // same remote), but `jj` is not aware of that since it thinks this is a + // different remote. So, the push should fail. + // + // But it succeeds! That's because the branch is created at the same location as + // it is on the remote. This would also work for a descendant. + // + // TODO: Saner test? let (stdout, stderr) = test_env.jj_cmd_ok(&workspace_root, &["git", "push", "--remote=other"]); insta::assert_snapshot!(stdout, @""); insta::assert_snapshot!(stderr, @r###" @@ -206,7 +213,7 @@ fn test_git_push_other_remote_has_branch() { } #[test] -fn test_git_push_not_fast_forward() { +fn test_git_push_forward_unexpectedly_moved() { let (test_env, workspace_root) = set_up(); // Move branch1 forward on the remote @@ -222,15 +229,114 @@ fn test_git_push_not_fast_forward() { test_env.jj_cmd_ok(&workspace_root, &["branch", "set", "branch1"]); // Pushing should fail - let assert = test_env - .jj_cmd(&workspace_root, &["git", "push"]) - .assert() - .code(1); - insta::assert_snapshot!(get_stdout_string(&assert), @""); - insta::assert_snapshot!(get_stderr_string(&assert), @r###" + let stderr = test_env.jj_cmd_failure(&workspace_root, &["git", "push"]); + insta::assert_snapshot!(stderr, @r###" Branch changes to push to origin: Move branch branch1 from 45a3aa29e907 to c35839cb8e8c - Error: The push conflicts with changes made on the remote (it is not fast-forwardable). + Error: Refusing to push a branch that unexpectedly moved on the remote. Affected refs: refs/heads/branch1 + Hint: Try fetching from the remote, then make the branch point to where you want it to be, and push again. + "###); +} + +#[test] +fn test_git_push_sideways_unexpectedly_moved() { + let (test_env, workspace_root) = set_up(); + + // Move branch1 forward on the remote + let origin_path = test_env.env_root().join("origin"); + test_env.jj_cmd_ok(&origin_path, &["new", "branch1", "-m=remote"]); + std::fs::write(origin_path.join("remote"), "remote").unwrap(); + test_env.jj_cmd_ok(&origin_path, &["branch", "set", "branch1"]); + insta::assert_snapshot!(get_branch_output(&test_env, &origin_path), @r###" + branch1: vruxwmqv fb645b4b remote + @git (behind by 1 commits): qpvuntsm 45a3aa29 (empty) description 1 + branch2: zsuskuln 8476341e (empty) description 2 + @git: zsuskuln 8476341e (empty) description 2 + "###); + test_env.jj_cmd_ok(&origin_path, &["git", "export"]); + + // Move branch1 sideways to another commit locally + test_env.jj_cmd_ok(&workspace_root, &["new", "root()", "-m=local"]); + std::fs::write(workspace_root.join("local"), "local").unwrap(); + test_env.jj_cmd_ok( + &workspace_root, + &["branch", "set", "branch1", "--allow-backwards"], + ); + insta::assert_snapshot!(get_branch_output(&test_env, &workspace_root), @r###" + branch1: kmkuslsw 0f8bf988 local + @origin (ahead by 1 commits, behind by 1 commits): lzmmnrxq 45a3aa29 (empty) description 1 + branch2: rlzusymt 8476341e (empty) description 2 + @origin: rlzusymt 8476341e (empty) description 2 + "###); + + let stderr = test_env.jj_cmd_failure(&workspace_root, &["git", "push"]); + insta::assert_snapshot!(stderr, @r###" + Branch changes to push to origin: + Force branch branch1 from 45a3aa29e907 to 0f8bf988588e + Error: Refusing to push a branch that unexpectedly moved on the remote. Affected refs: refs/heads/branch1 + Hint: Try fetching from the remote, then make the branch point to where you want it to be, and push again. + "###); +} + +// This tests whether the push checks that the remote branches are in expected +// positions. +#[test] +fn test_git_push_deletion_unexpectedly_moved() { + let (test_env, workspace_root) = set_up(); + + // Move branch1 forward on the remote + let origin_path = test_env.env_root().join("origin"); + test_env.jj_cmd_ok(&origin_path, &["new", "branch1", "-m=remote"]); + std::fs::write(origin_path.join("remote"), "remote").unwrap(); + test_env.jj_cmd_ok(&origin_path, &["branch", "set", "branch1"]); + insta::assert_snapshot!(get_branch_output(&test_env, &origin_path), @r###" + branch1: vruxwmqv fb645b4b remote + @git (behind by 1 commits): qpvuntsm 45a3aa29 (empty) description 1 + branch2: zsuskuln 8476341e (empty) description 2 + @git: zsuskuln 8476341e (empty) description 2 + "###); + test_env.jj_cmd_ok(&origin_path, &["git", "export"]); + + // Delete branch1 locally + test_env.jj_cmd_ok(&workspace_root, &["branch", "delete", "branch1"]); + insta::assert_snapshot!(get_branch_output(&test_env, &workspace_root), @r###" + branch1 (deleted) + @origin: lzmmnrxq 45a3aa29 (empty) description 1 + branch2: rlzusymt 8476341e (empty) description 2 + @origin: rlzusymt 8476341e (empty) description 2 + "###); + + let stderr = test_env.jj_cmd_failure(&workspace_root, &["git", "push", "--branch", "branch1"]); + insta::assert_snapshot!(stderr, @r###" + Branch changes to push to origin: + Delete branch branch1 from 45a3aa29e907 + Error: Refusing to push a branch that unexpectedly moved on the remote. Affected refs: refs/heads/branch1 + Hint: Try fetching from the remote, then make the branch point to where you want it to be, and push again. + "###); +} + +#[test] +fn test_git_push_creation_unexpectedly_already_exists() { + let (test_env, workspace_root) = set_up(); + + // Forget branch1 locally + test_env.jj_cmd_ok(&workspace_root, &["branch", "forget", "branch1"]); + + // Create a new branh1 + test_env.jj_cmd_ok(&workspace_root, &["new", "root()", "-m=new branch1"]); + std::fs::write(workspace_root.join("local"), "local").unwrap(); + test_env.jj_cmd_ok(&workspace_root, &["branch", "create", "branch1"]); + insta::assert_snapshot!(get_branch_output(&test_env, &workspace_root), @r###" + branch1: yostqsxw 4c595cf9 new branch1 + branch2: rlzusymt 8476341e (empty) description 2 + @origin: rlzusymt 8476341e (empty) description 2 + "###); + + let stderr = test_env.jj_cmd_failure(&workspace_root, &["git", "push"]); + insta::assert_snapshot!(stderr, @r###" + Branch changes to push to origin: + Add branch branch1 to 4c595cf9ac0a + Error: Refusing to push a branch that unexpectedly moved on the remote. Affected refs: refs/heads/branch1 Hint: Try fetching from the remote, then make the branch point to where you want it to be, and push again. "###); } diff --git a/lib/src/git.rs b/lib/src/git.rs index 4dbe7a38f4..6b8e132b41 100644 --- a/lib/src/git.rs +++ b/lib/src/git.rs @@ -29,9 +29,10 @@ use thiserror::Error; use crate::backend::{BackendError, CommitId}; use crate::commit::Commit; use crate::git_backend::GitBackend; +use crate::index::Index; use crate::object_id::ObjectId; use crate::op_store::{RefTarget, RefTargetOptionExt, RemoteRef, RemoteRefState}; -use crate::refs::BranchPushUpdate; +use crate::refs::{self, BranchPushUpdate}; use crate::repo::{MutableRepo, Repo}; use crate::revset::RevsetExpression; use crate::settings::GitSettings; @@ -1222,8 +1223,8 @@ pub enum GitPushError { name = REMOTE_NAME_FOR_LOCAL_GIT_REPO )] RemoteReservedForLocalGitRepo, - #[error("Push is not fast-forwardable")] - NotFastForward, + #[error("Refs in unexpected location: {0:?}")] + RefInUnexpectedLocation(Vec), #[error("Remote rejected the update of some refs (do you have permission to push to {0:?}?)")] RefUpdateRejected(Vec), // TODO: I'm sure there are other errors possible, such as transport-level errors, @@ -1235,15 +1236,15 @@ pub enum GitPushError { #[derive(Clone, Debug)] pub struct GitBranchPushTargets { pub branch_updates: Vec<(String, BranchPushUpdate)>, - pub force_pushed_branches: HashSet, } pub struct GitRefUpdate { pub qualified_name: String, - // TODO: We want this to be a `current_target: Option` for the expected current - // commit on the remote. It's a blunt "force" option instead until git2-rs supports the - // "push negotiation" callback (https://github.com/rust-lang/git2-rs/issues/733). - pub force: bool, + /// Expected position on the remote or None if we expect the ref to not + /// exist on the remote + /// + /// This is sourced from the local remote-tracking branch. + pub expected_current_target: Option, pub new_target: Option, } @@ -1260,11 +1261,11 @@ pub fn push_branches( .iter() .map(|(branch_name, update)| GitRefUpdate { qualified_name: format!("refs/heads/{branch_name}"), - force: targets.force_pushed_branches.contains(branch_name), + expected_current_target: update.old_target.clone(), new_target: update.new_target.clone(), }) .collect_vec(); - push_updates(git_repo, remote_name, &ref_updates, callbacks)?; + push_updates(mut_repo, git_repo, remote_name, &ref_updates, callbacks)?; // TODO: add support for partially pushed refs? we could update the view // excluding rejected refs, but the transaction would be aborted anyway @@ -1284,42 +1285,48 @@ pub fn push_branches( /// Pushes the specified Git refs without updating the repo view. pub fn push_updates( + repo: &dyn Repo, git_repo: &git2::Repository, remote_name: &str, updates: &[GitRefUpdate], callbacks: RemoteCallbacks<'_>, ) -> Result<(), GitPushError> { - let mut qualified_remote_refs = vec![]; + let mut qualified_remote_refs_expected_locations = HashMap::new(); let mut refspecs = vec![]; for update in updates { - qualified_remote_refs.push(update.qualified_name.as_str()); + qualified_remote_refs_expected_locations.insert( + update.qualified_name.as_str(), + update.expected_current_target.as_ref(), + ); if let Some(new_target) = &update.new_target { - refspecs.push(format!( - "{}{}:{}", - (if update.force { "+" } else { "" }), - new_target.hex(), - update.qualified_name - )); + // We always force-push. We use the push_negotiation callback in + // `push_refs` to check that the refs did not unexpectedly move on + // the remote. + refspecs.push(format!("+{}:{}", new_target.hex(), update.qualified_name)); } else { + // Prefixing this with `+` to force-push or not should make no + // difference. The push negotiation happens regardless, and wouldn't + // allow creating a branch if it's not a fast-forward. refspecs.push(format!(":{}", update.qualified_name)); } } - // TODO(ilyagr): this function will be reused to implement force-with-lease, so - // don't inline for now. If it's after 2024-06-01 or so, ilyagr may have - // forgotten to delete this comment. + // TODO(ilyagr): `push_refs`, or parts of it, should probably be inlined. This + // requires adjusting some tests. push_refs( + repo, git_repo, remote_name, - &qualified_remote_refs, + &qualified_remote_refs_expected_locations, &refspecs, callbacks, ) } fn push_refs( + repo: &dyn Repo, git_repo: &git2::Repository, remote_name: &str, - qualified_remote_refs: &[&str], + qualified_remote_refs_expected_locations: &HashMap<&str, Option<&CommitId>>, refspecs: &[String], callbacks: RemoteCallbacks<'_>, ) -> Result<(), GitPushError> { @@ -1333,39 +1340,170 @@ fn push_refs( GitPushError::InternalGitError(err) } })?; - let mut remaining_remote_refs: HashSet<_> = qualified_remote_refs.iter().copied().collect(); - let mut push_options = git2::PushOptions::new(); - let mut proxy_options = git2::ProxyOptions::new(); - proxy_options.auto(); - push_options.proxy_options(proxy_options); - let mut callbacks = callbacks.into_git(); - callbacks.push_update_reference(|refname, status| { - // The status is Some if the ref update was rejected - if status.is_none() { - remaining_remote_refs.remove(refname); - } - Ok(()) - }); - push_options.remote_callbacks(callbacks); - remote - .push(refspecs, Some(&mut push_options)) - .map_err(|err| match (err.class(), err.code()) { - (git2::ErrorClass::Reference, git2::ErrorCode::NotFastForward) => { - GitPushError::NotFastForward + let mut remaining_remote_refs: HashSet<_> = qualified_remote_refs_expected_locations + .keys() + .copied() + .collect(); + let mut failed_push_negotiations = vec![]; + let push_result = { + let mut push_options = git2::PushOptions::new(); + let mut proxy_options = git2::ProxyOptions::new(); + proxy_options.auto(); + push_options.proxy_options(proxy_options); + let mut callbacks = callbacks.into_git(); + callbacks.push_negotiation(|updates| { + for update in updates { + let dst_refname = update + .dst_refname() + .expect("Expect reference name to be valid UTF-8"); + let expected_remote_location = *qualified_remote_refs_expected_locations + .get(dst_refname) + .expect("Push is trying to move a ref it wasn't asked to move"); + let oid_to_maybe_commitid = + |oid: git2::Oid| (!oid.is_zero()).then(|| CommitId::from_bytes(oid.as_bytes())); + let actual_remote_location = oid_to_maybe_commitid(update.src()); + let local_location = oid_to_maybe_commitid(update.dst()); + + match allow_push( + repo.index(), + actual_remote_location.as_ref(), + expected_remote_location, + local_location.as_ref(), + ) { + Ok(PushAllowReason::NormalMatch) => {} + Ok(PushAllowReason::UnexpectedNoop) => { + tracing::info!( + "The push of {dst_refname} is unexpectedly a no-op, the remote branch \ + is already at {actual_remote_location:?}. We expected it to be at \ + {expected_remote_location:?}. We don't consider this an error.", + ); + } + Ok(PushAllowReason::ExceptionalFastforward) => { + // TODO(ilyagr): We could consider printing a user-facing message at + // this point. + tracing::info!( + "We allow the push of {dst_refname} to {local_location:?}, even \ + though it is unexpectedly at {actual_remote_location:?} on the \ + server rather than the expected {expected_remote_location:?}. The \ + desired location is a descendant of the actual location, and the \ + actual location is a descendant of the expected location.", + ); + } + Err(()) => { + // While we show debug info in the message with `--debug`, + // there's probably no need to show the detailed commit + // locations to the user normally. They should do a `jj git + // fetch`, and the resulting branch conflicts should contain + // all the information they need. + tracing::info!( + "Cannot push {dst_refname} to {local_location:?}; it is at \ + unexpectedly at {actual_remote_location:?} on the server as opposed \ + to the expected {expected_remote_location:?}", + ); + + failed_push_negotiations.push(dst_refname.to_string()); + } + } } - _ => GitPushError::InternalGitError(err), - })?; - drop(push_options); - if remaining_remote_refs.is_empty() { - Ok(()) - } else { - Err(GitPushError::RefUpdateRejected( - remaining_remote_refs - .iter() - .sorted() - .map(|name| name.to_string()) - .collect(), + if failed_push_negotiations.is_empty() { + Ok(()) + } else { + Err(git2::Error::from_str("failed push negotiation")) + } + }); + callbacks.push_update_reference(|refname, status| { + // The status is Some if the ref update was rejected + if status.is_none() { + remaining_remote_refs.remove(refname); + } + Ok(()) + }); + push_options.remote_callbacks(callbacks); + remote.push(refspecs, Some(&mut push_options)) + }; + if !failed_push_negotiations.is_empty() { + // If the push negotiation returned an error, `remote.push` would not + // have pushed anything and would have returned an error, as expected. + // However, the error it returns is not necessarily the error we'd + // expect. It also depends on the exact versions of `libgit2` and + // `git2.rs`. So, we cannot rely on it containing any useful + // information. See https://github.com/rust-lang/git2-rs/issues/1042. + assert!(push_result.is_err()); + failed_push_negotiations.sort(); + Err(GitPushError::RefInUnexpectedLocation( + failed_push_negotiations, )) + } else { + push_result?; + if remaining_remote_refs.is_empty() { + Ok(()) + } else { + Err(GitPushError::RefUpdateRejected( + remaining_remote_refs + .iter() + .sorted() + .map(|name| name.to_string()) + .collect(), + )) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PushAllowReason { + NormalMatch, + ExceptionalFastforward, + UnexpectedNoop, +} + +fn allow_push( + index: &dyn Index, + actual_remote_location: Option<&CommitId>, + expected_remote_location: Option<&CommitId>, + destination_location: Option<&CommitId>, +) -> Result { + if actual_remote_location == expected_remote_location { + return Ok(PushAllowReason::NormalMatch); + } + + // If the remote ref is in an unexpected location, we still allow some + // pushes, based on whether `jj git fetch` would result in a conflicted ref. + // + // For `merge_ref_targets` to work correctly, `actual_remote_location` must + // be a commit that we locally know about. + // + // This does not lose any generality since for `merge_ref_targets` to + // resolve to `local_target` below, it is conceptually necessary (but not + // sufficient) for the destination_location to be either a descendant of + // actual_remote_location or equal to it. Either way, we would know about that + // commit locally. + if !actual_remote_location.map_or(true, |id| index.has_id(id)) { + return Err(()); + } + let remote_target = RefTarget::resolved(actual_remote_location.cloned()); + let base_target = RefTarget::resolved(expected_remote_location.cloned()); + // The push destination is the local position of the ref + let local_target = RefTarget::resolved(destination_location.cloned()); + if refs::merge_ref_targets(index, &remote_target, &base_target, &local_target) == local_target { + // Fetch would not change the local branch, so the push is OK in spite of + // the discrepancy with the expected location. We return some debug info and + // verify some invariants before OKing the push. + Ok(if actual_remote_location == destination_location { + // This is the situation of what we call "A - B + A = A" + // conflicts, see also test_refs.rs and + // https://github.com/martinvonz/jj/blob/c9b44f382824301e6c0fdd6f4cbc52bb00c50995/lib/src/merge.rs#L92. + PushAllowReason::UnexpectedNoop + } else { + // Due to our ref merge rules, this case should happen if an only + // if: + // + // 1. This is a fast-forward. + // 2. The expected location is an ancestor of both the actual location and the + // destination (local position). + PushAllowReason::ExceptionalFastforward + }) + } else { + Err(()) } } diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index 802a3c83c6..959b303490 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -2483,16 +2483,34 @@ fn test_fetch_no_such_remote() { struct PushTestSetup { source_repo_dir: PathBuf, jj_repo: Arc, - initial_commit: Commit, - new_commit: Commit, -} - + main_commit: Commit, + child_of_main_commit: Commit, + parent_of_main_commit: Commit, + sideways_commit: Commit, +} + +/// Set up a situation where `main` is at `main_commit`, the child of +/// `parent_of_main_commit`, both in the source repo and in jj's clone of the +/// repo. In jj's clone, there are also two more commits, `child_of_main_commit` +/// and `sideways_commit`, arranged as follows: +/// +/// o child_of_main_commit +/// o main_commit +/// o parent_of_main_commit +/// | o sideways_commit +/// |/ +/// ~ root fn set_up_push_repos(settings: &UserSettings, temp_dir: &TempDir) -> PushTestSetup { let source_repo_dir = temp_dir.path().join("source"); let clone_repo_dir = temp_dir.path().join("clone"); let jj_repo_dir = temp_dir.path().join("jj"); let source_repo = git2::Repository::init_bare(&source_repo_dir).unwrap(); - let initial_git_commit = empty_git_commit(&source_repo, "refs/heads/main", &[]); + let parent_of_initial_git_commit = empty_git_commit(&source_repo, "refs/heads/main", &[]); + let initial_git_commit = empty_git_commit( + &source_repo, + "refs/heads/main", + &[&parent_of_initial_git_commit], + ); let clone_repo = git2::Repository::clone(source_repo_dir.to_str().unwrap(), clone_repo_dir).unwrap(); std::fs::create_dir(&jj_repo_dir).unwrap(); @@ -2516,24 +2534,29 @@ fn set_up_push_repos(settings: &UserSettings, temp_dir: &TempDir) -> PushTestSet get_git_backend(&jj_repo) .import_head_commits(&[jj_id(&initial_git_commit)]) .unwrap(); - let initial_commit = jj_repo + let main_commit = jj_repo .store() .get_commit(&jj_id(&initial_git_commit)) .unwrap(); + let parent_of_main_commit = jj_repo + .store() + .get_commit(&jj_id(&parent_of_initial_git_commit)) + .unwrap(); let mut tx = jj_repo.start_transaction(settings); - let new_commit = create_random_commit(tx.mut_repo(), settings) - .set_parents(vec![initial_commit.id().clone()]) + let sideways_commit = write_random_commit(tx.mut_repo(), settings); + let child_of_main_commit = create_random_commit(tx.mut_repo(), settings) + .set_parents(vec![main_commit.id().clone()]) .write() .unwrap(); tx.mut_repo().set_git_ref_target( "refs/remotes/origin/main", - RefTarget::normal(initial_commit.id().clone()), + RefTarget::normal(main_commit.id().clone()), ); tx.mut_repo().set_remote_branch( "main", "origin", RemoteRef { - target: RefTarget::normal(initial_commit.id().clone()), + target: RefTarget::normal(main_commit.id().clone()), // Caller expects the main branch is tracked. The corresponding local branch will // be created (or left as deleted) by caller. state: RemoteRefState::Tracking, @@ -2543,8 +2566,10 @@ fn set_up_push_repos(settings: &UserSettings, temp_dir: &TempDir) -> PushTestSet PushTestSetup { source_repo_dir, jj_repo, - initial_commit, - new_commit, + main_commit, + child_of_main_commit, + parent_of_main_commit, + sideways_commit, } } @@ -2560,11 +2585,10 @@ fn test_push_branches_success() { branch_updates: vec![( "main".to_owned(), BranchPushUpdate { - old_target: Some(setup.initial_commit.id().clone()), - new_target: Some(setup.new_commit.id().clone()), + old_target: Some(setup.main_commit.id().clone()), + new_target: Some(setup.child_of_main_commit.id().clone()), }, )], - force_pushed_branches: hashset! {}, }; let result = git::push_branches( tx.mut_repo(), @@ -2581,7 +2605,7 @@ fn test_push_branches_success() { .find_reference("refs/heads/main") .unwrap() .target(); - let new_oid = git_id(&setup.new_commit); + let new_oid = git_id(&setup.child_of_main_commit); assert_eq!(new_target, Some(new_oid)); // Check that the ref got updated in the cloned repo. This just tests our @@ -2597,12 +2621,12 @@ fn test_push_branches_success() { let view = tx.mut_repo().view(); assert_eq!( *view.get_git_ref("refs/remotes/origin/main"), - RefTarget::normal(setup.new_commit.id().clone()), + RefTarget::normal(setup.child_of_main_commit.id().clone()), ); assert_eq!( *view.get_remote_branch("main", "origin"), RemoteRef { - target: RefTarget::normal(setup.new_commit.id().clone()), + target: RefTarget::normal(setup.child_of_main_commit.id().clone()), state: RemoteRefState::Tracking, }, ); @@ -2630,11 +2654,10 @@ fn test_push_branches_deletion() { branch_updates: vec![( "main".to_owned(), BranchPushUpdate { - old_target: Some(setup.initial_commit.id().clone()), + old_target: Some(setup.main_commit.id().clone()), new_target: None, }, )], - force_pushed_branches: hashset! {}, }; let result = git::push_branches( tx.mut_repo(), @@ -2680,7 +2703,7 @@ fn test_push_branches_mixed_deletion_and_addition() { ( "main".to_owned(), BranchPushUpdate { - old_target: Some(setup.initial_commit.id().clone()), + old_target: Some(setup.main_commit.id().clone()), new_target: None, }, ), @@ -2688,11 +2711,10 @@ fn test_push_branches_mixed_deletion_and_addition() { "topic".to_owned(), BranchPushUpdate { old_target: None, - new_target: Some(setup.new_commit.id().clone()), + new_target: Some(setup.child_of_main_commit.id().clone()), }, ), ], - force_pushed_branches: hashset! {}, }; let result = git::push_branches( tx.mut_repo(), @@ -2709,7 +2731,7 @@ fn test_push_branches_mixed_deletion_and_addition() { .find_reference("refs/heads/topic") .unwrap() .target(); - assert_eq!(new_target, Some(git_id(&setup.new_commit))); + assert_eq!(new_target, Some(git_id(&setup.child_of_main_commit))); // Check that the main ref got deleted in the source repo assert!(source_repo.find_reference("refs/heads/main").is_err()); @@ -2720,12 +2742,12 @@ fn test_push_branches_mixed_deletion_and_addition() { assert!(view.get_remote_branch("main", "origin").is_absent()); assert_eq!( *view.get_git_ref("refs/remotes/origin/topic"), - RefTarget::normal(setup.new_commit.id().clone()), + RefTarget::normal(setup.child_of_main_commit.id().clone()), ); assert_eq!( *view.get_remote_branch("topic", "origin"), RemoteRef { - target: RefTarget::normal(setup.new_commit.id().clone()), + target: RefTarget::normal(setup.child_of_main_commit.id().clone()), state: RemoteRefState::Tracking, }, ); @@ -2741,21 +2763,17 @@ fn test_push_branches_mixed_deletion_and_addition() { fn test_push_branches_not_fast_forward() { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); - let mut setup = set_up_push_repos(&settings, &temp_dir); - let mut tx = setup.jj_repo.start_transaction(&settings); - let new_commit = write_random_commit(tx.mut_repo(), &settings); - setup.jj_repo = tx.commit("test"); + let setup = set_up_push_repos(&settings, &temp_dir); let mut tx = setup.jj_repo.start_transaction(&settings); let targets = GitBranchPushTargets { branch_updates: vec![( "main".to_owned(), BranchPushUpdate { - old_target: Some(setup.initial_commit.id().clone()), - new_target: Some(new_commit.id().clone()), + old_target: Some(setup.main_commit.id().clone()), + new_target: Some(setup.sideways_commit.id().clone()), }, )], - force_pushed_branches: hashset! {}, }; let result = git::push_branches( tx.mut_repo(), @@ -2764,47 +2782,188 @@ fn test_push_branches_not_fast_forward() { &targets, git::RemoteCallbacks::default(), ); - assert_eq!(result, Err(GitPushError::NotFastForward)); + assert_eq!(result, Ok(())); + + // Check that the ref got updated in the source repo + let source_repo = git2::Repository::open(&setup.source_repo_dir).unwrap(); + let new_target = source_repo + .find_reference("refs/heads/main") + .unwrap() + .target(); + assert_eq!(new_target, Some(git_id(&setup.sideways_commit))); } +// TODO(ilyagr): More tests for push safety checks were originally planned. We +// may want to add tests for when a branch unexpectedly moved backwards or +// unexpectedly does not exist for branch deletion. + #[test] -fn test_push_branches_not_fast_forward_with_force() { +fn test_push_updates_unexpectedly_moved_sideways_on_remote() { let settings = testutils::user_settings(); let temp_dir = testutils::new_temp_dir(); - let mut setup = set_up_push_repos(&settings, &temp_dir); - let mut tx = setup.jj_repo.start_transaction(&settings); - let new_commit = write_random_commit(tx.mut_repo(), &settings); - setup.jj_repo = tx.commit("test"); - let mut tx = setup.jj_repo.start_transaction(&settings); + let setup = set_up_push_repos(&settings, &temp_dir); - let targets = GitBranchPushTargets { - branch_updates: vec![( - "main".to_owned(), - BranchPushUpdate { - old_target: Some(setup.initial_commit.id().clone()), - new_target: Some(new_commit.id().clone()), - }, - )], - force_pushed_branches: hashset! { - "main".to_owned(), - }, + // The main branch is actually at `main_commit` on the remote. If we expect + // it to be at `sideways_commit`, it unexpectedly moved sideways from our + // perspective. + // + // We cannot delete it or move it anywhere else. However, "moving" it to the + // same place it already is is OK, following the behavior in + // `test_merge_ref_targets`. + // + // For each test, we check that the push succeeds if and only if the branch + // conflict `jj git fetch` would generate resolves to the push destination. + + let attempt_push_expecting_sideways = |target: Option| { + let targets = [GitRefUpdate { + qualified_name: "refs/heads/main".to_string(), + expected_current_target: Some(setup.sideways_commit.id().clone()), + new_target: target, + }]; + git::push_updates( + setup.jj_repo.as_ref(), + &get_git_repo(&setup.jj_repo), + "origin", + &targets, + git::RemoteCallbacks::default(), + ) }; - let result = git::push_branches( - tx.mut_repo(), - &get_git_repo(&setup.jj_repo), - "origin", - &targets, - git::RemoteCallbacks::default(), + + assert_matches!( + attempt_push_expecting_sideways(None), + Err(GitPushError::RefInUnexpectedLocation(_)) ); - assert_eq!(result, Ok(())); - // Check that the ref got updated in the source repo - let source_repo = git2::Repository::open(&setup.source_repo_dir).unwrap(); - let new_target = source_repo - .find_reference("refs/heads/main") - .unwrap() - .target(); - assert_eq!(new_target, Some(git_id(&new_commit))); + assert_matches!( + attempt_push_expecting_sideways(Some(setup.child_of_main_commit.id().clone())), + Err(GitPushError::RefInUnexpectedLocation(_)) + ); + + // Here, the local branch hasn't moved from `sideways_commit` from our + // perspective, but it moved to `main` on the remote. So, the conflict + // resolves to `main`. + // + // `jj` should not actually attempt a push in this case, but if it did, the + // push should fail. + assert_matches!( + attempt_push_expecting_sideways(Some(setup.sideways_commit.id().clone())), + Err(GitPushError::RefInUnexpectedLocation(_)) + ); + + assert_matches!( + attempt_push_expecting_sideways(Some(setup.parent_of_main_commit.id().clone())), + Err(GitPushError::RefInUnexpectedLocation(_)) + ); + + // Moving the branch to the same place it already is is OK. + assert_eq!( + attempt_push_expecting_sideways(Some(setup.main_commit.id().clone())), + Ok(()) + ); +} + +#[test] +fn test_push_updates_unexpectedly_moved_forward_on_remote() { + let settings = testutils::user_settings(); + let temp_dir = testutils::new_temp_dir(); + let setup = set_up_push_repos(&settings, &temp_dir); + + // The main branch is actually at `main_commit` on the remote. If we + // expected it to be at `parent_of_commit`, it unexpectedly moved forward + // from our perspective. + // + // We cannot delete it or move it sideways. (TODO: Moving it backwards is + // also disallowed; there is currently no test for this). However, "moving" + // it *forwards* is OK. This is allowed *only* in this test, i.e. if the + // actual location is the descendant of the expected location, and the new + // location is the descendant of that. + // + // For each test, we check that the push succeeds if and only if the branch + // conflict `jj git fetch` would generate resolves to the push destination. + + let attempt_push_expecting_parent = |target: Option| { + let targets = [GitRefUpdate { + qualified_name: "refs/heads/main".to_string(), + expected_current_target: Some(setup.parent_of_main_commit.id().clone()), + new_target: target, + }]; + git::push_updates( + setup.jj_repo.as_ref(), + &get_git_repo(&setup.jj_repo), + "origin", + &targets, + git::RemoteCallbacks::default(), + ) + }; + + assert_matches!( + attempt_push_expecting_parent(None), + Err(GitPushError::RefInUnexpectedLocation(_)) + ); + + assert_matches!( + attempt_push_expecting_parent(Some(setup.sideways_commit.id().clone())), + Err(GitPushError::RefInUnexpectedLocation(_)) + ); + + // Here, the local branch hasn't moved from `parent_of_main_commit`, but it + // moved to `main` on the remote. So, the conflict resolves to `main`. + // + // `jj` should not actually attempt a push in this case, but if it did, the push + // should fail. + assert_matches!( + attempt_push_expecting_parent(Some(setup.parent_of_main_commit.id().clone())), + Err(GitPushError::RefInUnexpectedLocation(_)) + ); + + // Moving the branch *forwards* is OK, as an exception matching our branch + // conflict resolution rules + assert_eq!( + attempt_push_expecting_parent(Some(setup.child_of_main_commit.id().clone())), + Ok(()) + ); +} + +#[test] +fn test_push_updates_unexpectedly_exists_on_remote() { + let settings = testutils::user_settings(); + let temp_dir = testutils::new_temp_dir(); + let setup = set_up_push_repos(&settings, &temp_dir); + + // The main branch is actually at `main_commit` on the remote. In this test, we + // expect it to not exist on the remote at all. + // + // We cannot move the branch backwards or sideways, but we *can* move it forward + // (as a special case). + // + // For each test, we check that the push succeeds if and only if the branch + // conflict `jj git fetch` would generate resolves to the push destination. + + let attempt_push_expecting_absence = |target: Option| { + let targets = [GitRefUpdate { + qualified_name: "refs/heads/main".to_string(), + expected_current_target: None, + new_target: target, + }]; + git::push_updates( + setup.jj_repo.as_ref(), + &get_git_repo(&setup.jj_repo), + "origin", + &targets, + git::RemoteCallbacks::default(), + ) + }; + + assert_matches!( + attempt_push_expecting_absence(Some(setup.parent_of_main_commit.id().clone())), + Err(GitPushError::RefInUnexpectedLocation(_)) + ); + + // We *can* move the branch forward even if we didn't expect it to exist + assert_eq!( + attempt_push_expecting_absence(Some(setup.child_of_main_commit.id().clone())), + Ok(()) + ); } #[test] @@ -2814,12 +2973,13 @@ fn test_push_updates_success() { let setup = set_up_push_repos(&settings, &temp_dir); let clone_repo = get_git_repo(&setup.jj_repo); let result = git::push_updates( + setup.jj_repo.as_ref(), &clone_repo, "origin", &[GitRefUpdate { qualified_name: "refs/heads/main".to_string(), - force: false, - new_target: Some(setup.new_commit.id().clone()), + expected_current_target: Some(setup.main_commit.id().clone()), + new_target: Some(setup.child_of_main_commit.id().clone()), }], git::RemoteCallbacks::default(), ); @@ -2831,7 +2991,7 @@ fn test_push_updates_success() { .find_reference("refs/heads/main") .unwrap() .target(); - let new_oid = git_id(&setup.new_commit); + let new_oid = git_id(&setup.child_of_main_commit); assert_eq!(new_target, Some(new_oid)); // Check that the ref got updated in the cloned repo. This just tests our @@ -2850,12 +3010,13 @@ fn test_push_updates_no_such_remote() { let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let result = git::push_updates( + setup.jj_repo.as_ref(), &get_git_repo(&setup.jj_repo), "invalid-remote", &[GitRefUpdate { qualified_name: "refs/heads/main".to_string(), - force: false, - new_target: Some(setup.new_commit.id().clone()), + expected_current_target: Some(setup.main_commit.id().clone()), + new_target: Some(setup.child_of_main_commit.id().clone()), }], git::RemoteCallbacks::default(), ); @@ -2868,12 +3029,13 @@ fn test_push_updates_invalid_remote() { let temp_dir = testutils::new_temp_dir(); let setup = set_up_push_repos(&settings, &temp_dir); let result = git::push_updates( + setup.jj_repo.as_ref(), &get_git_repo(&setup.jj_repo), "http://invalid-remote", &[GitRefUpdate { qualified_name: "refs/heads/main".to_string(), - force: false, - new_target: Some(setup.new_commit.id().clone()), + expected_current_target: Some(setup.main_commit.id().clone()), + new_target: Some(setup.child_of_main_commit.id().clone()), }], git::RemoteCallbacks::default(), ); diff --git a/lib/tests/test_refs.rs b/lib/tests/test_refs.rs index d56d1fb65a..59fa06758a 100644 --- a/lib/tests/test_refs.rs +++ b/lib/tests/test_refs.rs @@ -89,12 +89,29 @@ fn test_merge_ref_targets() { target4 ); - // Both added same target + // Both moved sideways ("A - B + A" - type conflict) + assert_eq!( + merge_ref_targets(index, &target4, &target3, &target4), + target4 + ); + + // Both added same target ("A - B + A" - type conflict) assert_eq!( merge_ref_targets(index, &target3, RefTarget::absent_ref(), &target3), target3 ); + // Both removed ("A - B + A" - type conflict) + assert_eq!( + merge_ref_targets( + index, + RefTarget::absent_ref(), + &target3, + RefTarget::absent_ref() + ), + RefTarget::absent() + ); + // Left added target, right added descendant target assert_eq!( merge_ref_targets(index, &target2, RefTarget::absent_ref(), &target3),