From e2a0017fe3adc0ac34178bd953ab660176816de2 Mon Sep 17 00:00:00 2001
From: Philip Metzger <philipmetzger@bluewin.ch>
Date: Sun, 18 Feb 2024 18:57:55 +0100
Subject: [PATCH] next/prev: Implement `next/prev --conflict`

This allows users to jump to the next conflict in the ancestors or children of
the start commit.

Continues work on #2126

Co-Authored-By: Noah Mayr <dev@noahmayr.com>
---
 CHANGELOG.md                         |   4 +
 cli/src/commands/next.rs             |  27 ++++---
 cli/src/commands/prev.rs             |  23 ++++--
 cli/tests/cli-reference@.md.snap     |   2 +
 cli/tests/test_next_prev_commands.rs | 108 +++++++++++++++++++++++++++
 5 files changed, 150 insertions(+), 14 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c0739ff7ac..d5a9ac0c4e1 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 8ae074fdb76..31fc22fc5b1 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<Commit> = 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 acd0c100edc..52c71e625e6 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 0ca166a0244..ee125c015ce 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 36d2582fced..7ddba115090 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])