diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3bc2caa3..c0988cfb71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * `jj squash`: the `-k` flag can be used as a shorthand for `--keep-emptied`. +* New command `jj simplify` will remove redundant parent edges. + ### Fixed bugs * Fixed panic when parsing invalid conflict markers of a particular form. diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 52c03c1743..398da511c4 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -46,6 +46,7 @@ mod restore; mod root; mod run; mod show; +mod simplify; mod sparse; mod split; mod squash; @@ -145,6 +146,7 @@ enum Command { // TODO: Flesh out. Run(run::RunArgs), Show(show::ShowArgs), + Simplify(simplify::SimplifyArgs), #[command(subcommand)] Sparse(sparse::SparseCommand), Split(split::SplitArgs), @@ -230,6 +232,7 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co Command::Revert(_args) => revert(), Command::Root(args) => root::cmd_root(ui, command_helper, args), Command::Run(args) => run::cmd_run(ui, command_helper, args), + Command::Simplify(args) => simplify::cmd_simplify(ui, command_helper, args), Command::Show(args) => show::cmd_show(ui, command_helper, args), Command::Sparse(args) => sparse::cmd_sparse(ui, command_helper, args), Command::Split(args) => split::cmd_split(ui, command_helper, args), diff --git a/cli/src/commands/simplify.rs b/cli/src/commands/simplify.rs new file mode 100644 index 0000000000..3c2cc72f0a --- /dev/null +++ b/cli/src/commands/simplify.rs @@ -0,0 +1,133 @@ +use std::collections::HashSet; + +use itertools::Itertools; +use jj_lib::commit::Commit; +use jj_lib::repo::Repo; +use jj_lib::revset::RevsetExpression; +use jj_lib::settings::UserSettings; + +use crate::cli_util::CommandHelper; +use crate::cli_util::RevisionArg; +use crate::cli_util::WorkspaceCommandTransaction; +use crate::command_error::user_error; +use crate::command_error::CommandError; +use crate::revset_util; +use crate::ui::Ui; + +/// Simplify parent edges for the specified revision(s). +/// +/// Removes any and all redundant parent edges going into any of the specified +/// revisions. By definition, this has no effect on any commit contents and +/// should not cause any working copy changes or conflicts. +/// +/// Immutable commits are automatically filtered from the specified inputs. +#[derive(clap::Args, Clone, Debug)] +pub(crate) struct SimplifyArgs { + /// Simplify specified revision(s) together with their trees of descendants + /// (can be repeated) + #[arg(long, short)] + sources: Vec, + /// Simplify specified revision(s) (can be repeated) + #[arg(long, short)] + revisions: Vec, +} + +pub(crate) fn cmd_simplify( + ui: &mut Ui, + command: &CommandHelper, + args: &SimplifyArgs, +) -> Result<(), CommandError> { + if args.sources.is_empty() && args.revisions.is_empty() { + return Err(user_error("no revisions specified")); + } + + let mut workspace_command = command.workspace_helper(ui)?; + let revs = RevsetExpression::descendants( + workspace_command + .parse_union_revsets(&args.sources)? + .expression(), + ) + .union( + workspace_command + .parse_union_revsets(&args.revisions)? + .expression(), + ) + .minus( + &revset_util::parse_immutable_heads_expression(&workspace_command.revset_parse_context())? + .ancestors(), + ); + let commits: Vec<_> = workspace_command + .attach_revset_evaluator(revs) + .evaluate_to_commits()? + .try_collect()?; + let orig_commits = commits.len(); + + let mut tx = workspace_command.start_transaction(); + let mut stats = SimplifyStats::default(); + for commit in commits { + stats.add(simplify_commit(command.settings(), &mut tx, &commit)?); + } + + if let Some(mut formatter) = ui.status_formatter() { + if !stats.is_empty() { + writeln!( + formatter, + "Removed {} edges from {} out of {} commits.", + stats.edges, stats.commits, orig_commits + )?; + } + } + tx.finish(ui, format!("simplified {} commits", orig_commits))?; + + Ok(()) +} + +#[derive(Default)] +struct SimplifyStats { + commits: usize, + edges: usize, +} + +impl SimplifyStats { + fn for_commit(edges: usize) -> Self { + Self { commits: 1, edges } + } + + fn add(&mut self, other: SimplifyStats) { + self.commits += other.commits; + self.edges += other.edges; + } + + fn is_empty(&self) -> bool { + self.commits == 0 && self.edges == 0 + } +} + +fn simplify_commit( + settings: &UserSettings, + tx: &mut WorkspaceCommandTransaction, + commit: &Commit, +) -> Result { + if commit.parent_ids().len() <= 1 { + return Ok(SimplifyStats::default()); + } + + let old_heads: HashSet<_> = commit.parent_ids().iter().cloned().collect(); + let new_heads: HashSet<_> = tx + .repo_mut() + .index() + .heads(&mut commit.parent_ids().iter()) + .into_iter() + .collect(); + if new_heads == old_heads { + return Ok(SimplifyStats::default()); + } + let removed = old_heads.len() - new_heads.len(); + + tx.repo_mut() + .rewrite_commit(settings, commit) + .set_parents(new_heads.into_iter().collect()) + .write()?; + + Ok(SimplifyStats::for_commit(removed)) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 78da14387d..0889e41fd8 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -75,6 +75,7 @@ This document contains the help content for the `jj` command-line program. * [`jj restore`↴](#jj-restore) * [`jj root`↴](#jj-root) * [`jj show`↴](#jj-show) +* [`jj simplify`↴](#jj-simplify) * [`jj sparse`↴](#jj-sparse) * [`jj sparse edit`↴](#jj-sparse-edit) * [`jj sparse list`↴](#jj-sparse-list) @@ -139,6 +140,7 @@ To get started, see the tutorial at https://martinvonz.github.io/jj/latest/tutor * `restore` — Restore paths from another revision * `root` — Show the current workspace root directory * `show` — Show commit description and changes in a revision +* `simplify` — Simplify parent edges for the specified revision(s) * `sparse` — Manage which paths from the working-copy commit are present in the working copy * `split` — Split a revision in two * `squash` — Move changes from a revision into another revision @@ -1811,6 +1813,23 @@ Show commit description and changes in a revision +## `jj simplify` + +Simplify parent edges for the specified revision(s). + +Removes any and all redundant parent edges going into any of the specified revisions. By definition, this has no effect on any commit contents and should not cause any working copy changes or conflicts. + +Immutable commits are automatically filtered from the specified inputs. + +**Usage:** `jj simplify [OPTIONS]` + +###### **Options:** + +* `-s`, `--sources ` — Simplify specified revision(s) together with their trees of descendants (can be repeated) +* `-r`, `--revisions ` — Simplify specified revision(s) (can be repeated) + + + ## `jj sparse` Manage which paths from the working-copy commit are present in the working copy diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index 1420e30cd0..7788c6e626 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -61,6 +61,7 @@ mod test_revset_output; mod test_root; mod test_shell_completion; mod test_show_command; +mod test_simplify_command; mod test_sparse_command; mod test_split_command; mod test_squash_command; diff --git a/cli/tests/test_simplify_command.rs b/cli/tests/test_simplify_command.rs new file mode 100644 index 0000000000..1f9e72d784 --- /dev/null +++ b/cli/tests/test_simplify_command.rs @@ -0,0 +1,127 @@ +// Copyright 2024 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 std::path::PathBuf; + +use test_case::test_case; + +use crate::common::TestEnvironment; + +fn create_repo() -> (TestEnvironment, PathBuf) { + 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"); + + (test_env, repo_path) +} + +fn create_commit(test_env: &TestEnvironment, repo_path: &Path, name: &str, parents: &[&str]) { + let mut args = vec!["new", "-m", name]; + 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]); +} + +#[test] +fn test_simplify_no_args() { + let (test_env, repo_path) = create_repo(); + + let stderr = test_env.jj_cmd_failure(&repo_path, &["simplify"]); + insta::assert_snapshot!(stderr, @r###" + Error: no revisions specified + "###); +} + +#[test] +fn test_simplify_no_commits() { + let (test_env, repo_path) = create_repo(); + + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["simplify", "-r", "root()"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Nothing changed. + "###); +} + +#[test] +fn test_simplify_no_change() { + let (test_env, repo_path) = create_repo(); + + create_commit(&test_env, &repo_path, "a", &["root()"]); + create_commit(&test_env, &repo_path, "b", &["a"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-r", "all()", "-T", "description"]); + insta::assert_snapshot!(stdout, @r###" + @ b + ○ a + ◆ + "###); + + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["simplify", "-s", "@-"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Nothing changed. + "###); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-r", "all()", "-T", "description"]); + insta::assert_snapshot!(stdout, @r###" + @ b + ○ a + ◆ + "###); +} + +#[test_case(&["simplify", "-r", "@", "-r", "@-"] ; "revisions")] +#[test_case(&["simplify", "-s", "@-"] ; "sources")] +#[test_case(&["simplify", "-s", "@--"] ; "filter root")] +fn test_simplify_redundant_parent(args: &[&str]) { + let (test_env, repo_path) = create_repo(); + + create_commit(&test_env, &repo_path, "a", &["root()"]); + create_commit(&test_env, &repo_path, "b", &["a"]); + create_commit(&test_env, &repo_path, "c", &["a", "b"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-r", "all()", "-T", "description"]); + insta::allow_duplicates! { + insta::assert_snapshot!(stdout, @r###" + @ c + ├─╮ + │ ○ b + ├─╯ + ○ a + ◆ + "###); + } + + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, args); + insta::allow_duplicates! { + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Removed 1 edges from 1 out of 3 commits. + Working copy now at: royxmykx 0ac2063b c | c + Parent commit : zsuskuln 1394f625 b | b + "###); + } + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-r", "all()", "-T", "description"]); + insta::allow_duplicates! { + insta::assert_snapshot!(stdout, @r###" + @ c + ○ b + ○ a + ◆ + "###); + } +}