diff --git a/CHANGELOG.md b/CHANGELOG.md
index 808df81a27..6d6d65fda5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -94,6 +94,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   now shorter within the default log revset. You can override the default by
   setting the `revsets.short-prefixes` config to a different revset.
 
+* `jj next` and `jj prev` are added, these allow you to traverse the history
+  in a linear style, see [#NNN](https://github.com/martinvonz/jj/issues/NNN)
+  for further pending improvements.
+
 ### Fixed bugs
 
 * Modify/delete conflicts now include context lines
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index b523a7fe8f..bee7feba65 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -104,10 +104,12 @@ enum Commands {
     Merge(NewArgs),
     Move(MoveArgs),
     New(NewArgs),
+    Next(NextArgs),
     Obslog(ObslogArgs),
     #[command(subcommand)]
     #[command(visible_alias = "op")]
     Operation(operation::OperationCommands),
+    Prev(PrevArgs),
     Rebase(RebaseArgs),
     Resolve(ResolveArgs),
     Restore(RestoreArgs),
@@ -515,6 +517,70 @@ struct NewArgs {
     insert_before: bool,
 }
 
+/// Move the current working copy commit to the next child revision in the
+/// repository. The command moves you to the next child in a linear fashion.
+///
+/// F        F  @
+/// |        | /
+/// C  @ =>  C
+/// | /      |
+/// B        B
+///
+/// If `edit` is passed as an argument, it will move you directly to the child
+/// revision.
+///
+/// F        F
+/// |        |
+/// C        C
+/// |        |
+/// B  @  => @
+/// | /      |
+/// A        A
+// TODO(#NNN): Handle multiple child revisions properly.
+#[derive(clap::Args, Clone, Debug)]
+struct NextArgs {
+    /// How many revisions to move forward. By default advances to the next
+    /// child.
+    #[arg(default_value = "1")]
+    amount: usize,
+    /// Instead of moving the empty commit from `jj new`, edit the child
+    /// revision directly. This mirrors the behavior of Mercurial and
+    /// Sapling.
+    #[arg(long)]
+    edit: Option<bool>,
+}
+
+/// Move the working copy commit to the parent of the current revision.
+/// The command moves you to the parent in a linear fashion.
+///
+/// F @     F
+/// |/      |  
+/// A   =>  A  @
+/// |       | /
+/// B       B
+///
+/// If `edit` is passed as an argument, it will move the working copy commit
+/// directly to the parent.
+///
+/// F @     F
+/// |/      |
+/// C   =>  C
+/// |       |
+/// B       @
+/// |       |
+/// A       A
+// TODO(#NNN): Handle multiple parents, e.g merges.
+#[derive(clap::Args, Clone, Debug)]
+struct PrevArgs {
+    /// How many revisions to move backward. By default moves to the parent.
+    #[arg(default_value = "1")]
+    amount: usize,
+    /// Edit the parent directly, instead of moving the empty revision.
+    /// This mirrors the behavior of Mercurial and Sapling.
+    #[arg(long)]
+    edit: Option<bool>,
+}
+
 /// Move changes from one revision into another
 ///
 /// Use `--interactive` to move only part of the source revision into the
@@ -2189,6 +2255,228 @@ fn cmd_new(ui: &mut Ui, command: &CommandHelper, args: &NewArgs) -> Result<(), C
     Ok(())
 }
 
+fn cmd_next(ui: &mut Ui, command: &CommandHelper, args: &NextArgs) -> Result<(), CommandError> {
+    let mut workspace_command = command.workspace_helper(ui)?;
+    let edit = if args.edit.is_some() { true } else { false };
+    let amount = args.amount;
+    assert!(amount == 1 || amount > 1);
+    let current_wc_id = workspace_command
+        .get_wc_commit_id()
+        .ok_or_else(|| user_error(format!("This command requires a working copy")))?;
+    let current_wc = workspace_command
+        .repo()
+        .store()
+        .get_commit(&current_wc_id)?;
+    // TODO(#NNN): This currently depends on order in which the parents are
+    // returned, make it configurable.
+    let parents = current_wc.parents();
+    let parent = parents
+        .first()
+        .ok_or_else(|| user_error("unable to determine parent"))?;
+    let parent_commit = RevsetExpression::commit(parent.id().clone());
+    // Collect all descendants of our parent.
+    // TODO(#NNN) We currently cannot deal with multiple children, which result
+    // from branches. Fix it when --interactive is implemented.
+    let children: Vec<Commit> = RevsetExpression::descendants(&parent_commit)
+        .resolve(workspace_command.repo().as_ref())?
+        .evaluate(workspace_command.repo().as_ref())?
+        .iter()
+        .commits(workspace_command.repo().store())
+        .try_collect()?;
+
+    let current_id = current_wc_id.hex();
+    // Handle the simple `jj next` call.
+    if amount == 1 {
+        // If the parent is the last commmit in the repository and we're not editing,
+        // just move the working-copy commit to the end. This is just a wrapped `jj
+        // new`.
+        if children.is_empty() {
+            let mut tx =
+                workspace_command.start_transaction(&format!("next: {current_id} -> new commit"));
+            // As stated above, we can only move the working-copy commit if we're not
+            // editing.
+            if edit {
+                return Err(user_error(format!(
+                    "next cannot edit the next commit at the end of the history"
+                )));
+            }
+            let merged_tree = merge_commit_trees(tx.repo(), &[parent.clone()]);
+            // Move the working-copy commit.
+            let new_wc_revision = tx
+                .mut_repo()
+                .new_commit(
+                    command.settings(),
+                    vec![parent.id().clone()],
+                    merged_tree.id().clone(),
+                )
+                .write()?;
+            tx.edit(&new_wc_revision).unwrap();
+            tx.finish(ui)?;
+            return Ok(());
+        }
+
+        // The target is the first child.
+        let target = children.first().unwrap();
+        workspace_command.check_rewritable(&target)?;
+        let target_id = target.id().hex();
+
+        let message = if !edit {
+            format!("next: {current_id} -> {target_id}")
+        } else {
+            format!("next: {current_id} -> editing {target_id}")
+        };
+        let mut tx = workspace_command.start_transaction(&message);
+
+        // Move to the revision.
+        if edit {
+            tx.edit(&target).unwrap();
+            tx.finish(ui)?;
+            return Ok(());
+        }
+        let merged_tree = merge_commit_trees(tx.repo(), &[target.clone()]);
+        let new_wc_revision = tx
+            .mut_repo()
+            .new_commit(
+                command.settings(),
+                vec![target.id().clone()],
+                merged_tree.id().clone(),
+            )
+            .write()?;
+        tx.edit(&new_wc_revision).unwrap();
+        tx.finish(ui)?;
+        return Ok(());
+    }
+    assert!(amount > 1, "Expected to descend to further children");
+    let target = children.iter().nth(amount - 1).ok_or_else(|| {
+        user_error(format!(
+            "unable to find target as {amount} is larger than all following commits"
+        ))
+    })?;
+    let target_id = target.id().hex();
+    let message = if !edit {
+        format!("next: {current_id} -> {target_id}")
+    } else {
+        format!("next: {current_id} -> editing {target_id}")
+    };
+    let mut tx = workspace_command.start_transaction(&message);
+
+    // Move to the target.
+    if edit {
+        tx.edit(&target).unwrap();
+        tx.finish(ui)?;
+        return Ok(());
+    }
+    // Make the target the parent of the new working-copy commit.
+    let merged_tree = merge_commit_trees(tx.repo(), &[target.clone()]);
+    let new_wc_revision = tx
+        .mut_repo()
+        .new_commit(
+            command.settings(),
+            vec![target.id().clone()],
+            merged_tree.id().clone(),
+        )
+        .write()?;
+    tx.edit(&new_wc_revision).unwrap();
+    tx.finish(ui)?;
+    Ok(())
+}
+
+fn cmd_prev(ui: &mut Ui, command: &CommandHelper, args: &PrevArgs) -> Result<(), CommandError> {
+    let mut workspace_command = command.workspace_helper(ui)?;
+    let edit = if args.edit.is_some() { true } else { false };
+    let amount = args.amount;
+    assert!(amount == 1 || amount > 1);
+    let current_wc_id = workspace_command
+        .get_wc_commit_id()
+        .ok_or_else(|| user_error(format!("This command requires a working copy")))?;
+    let current_wc = workspace_command
+        .repo()
+        .store()
+        .get_commit(&current_wc_id)?;
+    let current_id = current_wc.id().hex();
+    let parents = RevsetExpression::commit(current_wc_id.clone()).parents();
+    // Collect all ancestors up until the root commit.
+    let all_ancestors: Vec<Commit> = parents
+        .ancestors()
+        .resolve(workspace_command.repo().as_ref())?
+        .evaluate(workspace_command.repo().as_ref())?
+        .iter()
+        .commits(workspace_command.repo().store())
+        .try_collect()?;
+    // Handle the simple case of a basic `prev` call.
+    if amount == 1 {
+        // The direct parent is the first ancestor.
+        // TODO(#NNN): Handle multiple parents correctly, e.g prompt if we're
+        // interactive.
+        let parent = all_ancestors.first().unwrap();
+        workspace_command.check_rewritable(&parent)?;
+        let parent_id = parent.id();
+        // Omit the "moved N commits" from the message.
+        let mut tx = workspace_command.start_transaction(&format!(
+            "prev: {current_id} -> {parent_id}",
+            parent_id = parent_id.hex()
+        ));
+        let root_commit = tx.base_repo().store().root_commit();
+        // If we're editing, just move to the revision directly.
+        if edit {
+            if parent_id == root_commit.id() {
+                return Err(user_error("Editing the root commit is not allowed."));
+            }
+            tx.edit(&parent).unwrap();
+            tx.finish(ui)?;
+            return Ok(());
+        }
+        let merged_tree = merge_commit_trees(tx.repo(), &[parent.clone()]);
+        // Make the workspace commit a descendant of the parent.
+        let new_wc_revision = tx
+            .mut_repo()
+            .new_commit(
+                command.settings(),
+                vec![parent_id.clone()],
+                merged_tree.id().clone(),
+            )
+            .write()?;
+        tx.edit(&new_wc_revision).unwrap();
+        tx.finish(ui)?;
+        return Ok(());
+    }
+    assert!(amount > 1, "Expected more parents to traverse");
+    let target = all_ancestors.iter().nth(amount - 1).unwrap();
+    let target_id = target.id().hex();
+    let message = if !edit {
+        format!("prev: moved {amount} commits {current_id} -> {target_id}")
+    } else {
+        format!("prev: moved {amount} commits editing {current_id} -> {target_id}")
+    };
+    let mut tx = workspace_command.start_transaction(&message);
+    let root_commit = tx.base_repo().store().root_commit();
+    // You still cannot edit the root commit.
+    if *target == root_commit {
+        return Err(user_error("you cannot edit the root commit"));
+    }
+    // We're editing, just move to the commit.
+    if edit {
+        tx.edit(&target).unwrap();
+        tx.finish(ui)?;
+        return Ok(());
+    }
+    // Create a child revision for our new working-copy commit.
+    let merged_tree = merge_commit_trees(tx.repo(), &[target.clone()]);
+    let target_id = target.id();
+    // Make the working-copy commit a descendant of the target.
+    let new_wc_revision = tx
+        .mut_repo()
+        .new_commit(
+            command.settings(),
+            vec![target_id.clone()],
+            merged_tree.id().clone(),
+        )
+        .write()?;
+    tx.edit(&new_wc_revision).unwrap();
+    tx.finish(ui)?;
+    Ok(())
+}
+
 fn combine_messages(
     repo: &ReadonlyRepo,
     source: &Commit,
@@ -3564,6 +3852,8 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co
         Commands::Duplicate(sub_args) => cmd_duplicate(ui, command_helper, sub_args),
         Commands::Abandon(sub_args) => cmd_abandon(ui, command_helper, sub_args),
         Commands::Edit(sub_args) => cmd_edit(ui, command_helper, sub_args),
+        Commands::Next(sub_args) => cmd_next(ui, command_helper, sub_args),
+        Commands::Prev(sub_args) => cmd_prev(ui, command_helper, sub_args),
         Commands::New(sub_args) => cmd_new(ui, command_helper, sub_args),
         Commands::Move(sub_args) => cmd_move(ui, command_helper, sub_args),
         Commands::Squash(sub_args) => cmd_squash(ui, command_helper, sub_args),
diff --git a/tests/test_next_prev_commands.rs b/tests/test_next_prev_commands.rs
new file mode 100644
index 0000000000..4d2cea6ada
--- /dev/null
+++ b/tests/test_next_prev_commands.rs
@@ -0,0 +1,98 @@
+// 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.
+//
+// TODO: Finish tests for `prev` and `next`
+
+use crate::common::{get_stderr_string, TestEnvironment};
+
+pub mod common;
+
+#[test]
+fn test_next_simple() {
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+    // Create a simple linear history, which we'll traverse.
+    test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "first"]);
+    test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "second"]);
+    test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "third"]);
+    test_env.jj_cmd_success(test_env.env_root(), &["edit", ""]);
+    test_env.jj_cmd_success(test_env.env_root(), &["next"]);
+    insta::assert_snapshot!()
+}
+
+#[test]
+fn test_next_multiple_without_root() {
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+    insta::assert_snapshot!()
+}
+
+#[test]
+fn test_prev_simple() {
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+    test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "first"]);
+    test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "second"]);
+    test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "third"]);
+    test_env.jj_cmd_success(test_env.env_root(), &["prev"]);
+    // The working copy commit is now a child of "second".
+    insta::assert_snapshot!()
+}
+
+#[test]
+fn test_prev_multiple_without_root() {
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+}
+
+#[test]
+fn test_next_fails_on_branching_children() {
+    // TODO(#NNN): Fix this behavior
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+}
+
+#[test]
+fn test_prev_fails_on_multiple_parents() {
+    // TODO(#NNN): Fix this behavior
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+}
+
+#[test]
+fn test_prev_onto_root_fails() {
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+}
+
+#[test]
+fn test_prev_editing() {
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+}
+
+#[test]
+fn test_next_editing() {
+    let test_env = TestEnvironment::default();
+    test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]);
+    let repo_path = test_env.env_root().join("repo");
+}