diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0739ff7a..d5a9ac0c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * New diff option `jj diff --name-only` allows for easier shell scripting. +* `jj prev` and `jj next` have gained a `--conflict` flag which moves you + to the next conflict in a child commit. + ### Fixed bugs ## [0.18.0] - 2024-06-05 @@ -90,6 +93,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 were global flags and specifying them once would insert the new commit before/ after all the specified commits. + ### Deprecations * Attempting to alias a built-in command now gives a warning, rather than being diff --git a/cli/src/commands/next.rs b/cli/src/commands/next.rs index 8ae074fdb7..31fc22fc5b 100644 --- a/cli/src/commands/next.rs +++ b/cli/src/commands/next.rs @@ -17,7 +17,7 @@ use std::io::Write; use itertools::Itertools; use jj_lib::commit::Commit; use jj_lib::repo::Repo; -use jj_lib::revset::{RevsetExpression, RevsetIteratorExt}; +use jj_lib::revset::{RevsetExpression, RevsetFilterPredicate, RevsetIteratorExt}; use crate::cli_util::{short_commit_hash, CommandHelper, WorkspaceCommandHelper}; use crate::command_error::{user_error, CommandError}; @@ -65,6 +65,9 @@ pub(crate) struct NextArgs { /// edit`). #[arg(long, short)] edit: bool, + /// Jump to the next conflicted descendant. + #[arg(long, conflicts_with = "offset")] + conflict: bool, } pub fn choose_commit<'a>( @@ -117,21 +120,27 @@ pub(crate) fn cmd_next( let wc_revset = RevsetExpression::commit(current_wc_id.clone()); // If we're editing, start at the working-copy commit. Otherwise, start from // its direct parent(s). - let target_revset = if edit { - wc_revset.descendants_at(args.offset) + let start_revset = if edit { + wc_revset.clone() } else { - wc_revset - .parents() - .descendants_at(args.offset) - // In previous versions we subtracted `wc_revset.descendants()`. That's - // unnecessary now that --edit is implied if `@` has descendants. - .minus(&wc_revset) + wc_revset.parents() }; + + let target_revset = if args.conflict { + start_revset + .descendants() + .filtered(RevsetFilterPredicate::HasConflict) + .roots() + } else { + start_revset.descendants_at(args.offset).minus(&wc_revset) + }; + let targets: Vec = target_revset .evaluate_programmatic(workspace_command.repo().as_ref())? .iter() .commits(workspace_command.repo().store()) .try_collect()?; + let target = match targets.as_slice() { [target] => target, [] => { diff --git a/cli/src/commands/prev.rs b/cli/src/commands/prev.rs index acd0c100ed..5d0e8b11b5 100644 --- a/cli/src/commands/prev.rs +++ b/cli/src/commands/prev.rs @@ -14,7 +14,7 @@ use itertools::Itertools; use jj_lib::repo::Repo; -use jj_lib::revset::{RevsetExpression, RevsetIteratorExt}; +use jj_lib::revset::{RevsetExpression, RevsetFilterPredicate, RevsetIteratorExt}; use crate::cli_util::{short_commit_hash, CommandHelper}; use crate::command_error::{user_error, CommandError}; @@ -59,6 +59,9 @@ pub(crate) struct PrevArgs { /// Edit the parent directly, instead of moving the working-copy commit. #[arg(long, short)] edit: bool, + /// Jump to the previous conflicted ancestor. + #[arg(long, conflicts_with = "offset")] + conflict: bool, } pub(crate) fn cmd_prev( @@ -79,11 +82,21 @@ pub(crate) fn cmd_prev( // If we're editing, start at the working-copy commit. Otherwise, start from // its direct parent(s). let target_revset = if edit { - RevsetExpression::commit(current_wc_id.clone()).ancestors_at(args.offset) - } else { RevsetExpression::commit(current_wc_id.clone()) - .parents() - .ancestors_at(args.offset) + } else { + RevsetExpression::commit(current_wc_id.clone()).parents() + }; + let target_revset = if args.conflict { + // If people desire to move to the root conflict, replace the `heads()` below with + // `roots(). But let's wait for feedback. + target_revset + .ancestors() + .filtered(RevsetFilterPredicate::HasConflict) + // We need to filter out empty commits to not land on empty working-copies lying around. + .minus(&RevsetExpression::is_empty()) + .heads() + } else { + target_revset.ancestors_at(args.offset) }; let targets: Vec<_> = target_revset .evaluate_programmatic(workspace_command.repo().as_ref())? diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 0ca166a024..ee125c015c 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -1163,6 +1163,7 @@ implied. ###### **Options:** * `-e`, `--edit` — Instead of creating a new working-copy commit on top of the target commit (like `jj new`), edit the target commit directly (like `jj edit`) +* `--conflict` — Jump to the next conflicted descendant @@ -1399,6 +1400,7 @@ implied. ###### **Options:** * `-e`, `--edit` — Edit the parent directly, instead of moving the working-copy commit +* `--conflict` — Jump to the previous conflicted ancestor diff --git a/cli/tests/test_next_prev_commands.rs b/cli/tests/test_next_prev_commands.rs index 36d2582fce..7ddba11509 100644 --- a/cli/tests/test_next_prev_commands.rs +++ b/cli/tests/test_next_prev_commands.rs @@ -567,6 +567,114 @@ fn test_next_editing() { "###); } +#[test] +fn test_prev_conflict() { + // Make the first commit our new parent. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + let file_path = repo_path.join("content.txt"); + std::fs::write(&file_path, "first").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "first"]); + std::fs::write(&file_path, "second").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "third"]); + // Create a conflict in the first commit, where we'll jump to. + test_env.jj_cmd_ok(&repo_path, &["edit", "description(first)"]); + std::fs::write(&file_path, "first+1").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new", "description(third)"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "fourth"]); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["prev", "--conflict"]); + // We now should be a child of `fourth`. + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: vruxwmqv b1ea981a (conflict) (empty) (no description set) + Parent commit : rlvkpnrz c26675ba (conflict) second + There are unresolved conflicts at these paths: + content.txt 2-sided conflict + "###); +} + +#[test] +fn test_prev_conflict_editing() { + // Edit the third commit. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + let file_path = repo_path.join("content.txt"); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + std::fs::write(&file_path, "second").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "third"]); + // Create a conflict in the third commit, where we'll jump to. + test_env.jj_cmd_ok(&repo_path, &["edit", "description(first)"]); + std::fs::write(&file_path, "first text").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new", "description(third)"]); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["prev", "--conflict", "--edit"]); + // We now should be editing the third commit. + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: kkmpptxz 26b1439f (conflict) third + Parent commit : rlvkpnrz 55b5d11a (empty) second + There are unresolved conflicts at these paths: + content.txt 2-sided conflict + "###); +} + +#[test] +fn test_next_conflict() { + // There is a conflict in the second commit, so after next it should be the new + // parent. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + let file_path = repo_path.join("content.txt"); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "first"]); + std::fs::write(&file_path, "second").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + // Create a conflict in the second commit. + test_env.jj_cmd_ok(&repo_path, &["edit", "description(first)"]); + std::fs::write(&file_path, "first").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new", "description(second)"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_ok(&repo_path, &["new", "description(second)"]); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["next", "--conflict"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: vruxwmqv b69eca51 (conflict) (empty) (no description set) + Parent commit : rlvkpnrz fa43d820 (conflict) second + There are unresolved conflicts at these paths: + content.txt 2-sided conflict + "###); +} + +#[test] +fn test_next_conflict_editing() { + // There is a conflict in the third commit, so after next it should be our + // working copy. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + let file_path = repo_path.join("content.txt"); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "first"]); + std::fs::write(&file_path, "second").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + // Create a conflict in the third commit. + std::fs::write(&file_path, "third").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["edit", "description(second)"]); + std::fs::write(&file_path, "modified second").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new", "@+"]); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["next", "--conflict", "--edit"]); + // We now should be editing the third commit. + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: royxmykx 08fda952 (conflict) (empty) (no description set) + Parent commit : kkmpptxz 69ff337c (conflict) (no description set) + There are unresolved conflicts at these paths: + content.txt 2-sided conflict + "###); +} + fn get_log_output(test_env: &TestEnvironment, cwd: &Path) -> String { let template = r#"separate(" ", change_id.short(), local_branches, description)"#; test_env.jj_cmd_success(cwd, &["log", "-T", template])