From 5fcc549eabc4e814b194e63de2d2c0b9b308e65b Mon Sep 17 00:00:00 2001 From: Remo Senekowitsch Date: Sat, 16 Nov 2024 10:30:42 +0100 Subject: [PATCH] completion: teach commands about files This is heavily based on Benjamin Tan's fish completions: https://gist.github.com/bnjmnt4n/9f47082b8b6e6ed2b2a805a1516090c8 Some differences include: - The end of a `--from`, `--to` ranges is also considered. - `jj log` is not completed (yet). It has a different `--revisions` argument that requires some special handling. --- cli/src/commands/commit.rs | 7 +- cli/src/commands/diff.rs | 5 +- cli/src/commands/file/annotate.rs | 6 +- cli/src/commands/file/chmod.rs | 7 +- cli/src/commands/file/show.rs | 7 +- cli/src/commands/file/untrack.rs | 8 +- cli/src/commands/interdiff.rs | 5 +- cli/src/commands/resolve.rs | 5 +- cli/src/commands/restore.rs | 5 +- cli/src/commands/split.rs | 5 +- cli/src/commands/squash.rs | 6 +- cli/src/complete.rs | 275 ++++++++++++++++++++++++++++++ cli/tests/test_completion.rs | 212 +++++++++++++++++++++++ 13 files changed, 542 insertions(+), 11 deletions(-) diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs index 86208b3d84..2e79432958 100644 --- a/cli/src/commands/commit.rs +++ b/cli/src/commands/commit.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use clap_complete::ArgValueCandidates; use jj_lib::backend::Signature; use jj_lib::object_id::ObjectId; use jj_lib::repo::Repo; @@ -20,6 +21,7 @@ use tracing::instrument; use crate::cli_util::CommandHelper; use crate::command_error::user_error; use crate::command_error::CommandError; +use crate::complete; use crate::description_util::description_template; use crate::description_util::edit_description; use crate::description_util::join_message_paragraphs; @@ -40,7 +42,10 @@ pub(crate) struct CommitArgs { #[arg(long = "message", short, value_name = "MESSAGE")] message_paragraphs: Vec, /// Put these paths in the first commit - #[arg(value_hint = clap::ValueHint::AnyPath)] + #[arg( + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCandidates::new(complete::modified_files), + )] paths: Vec, /// Reset the author to the configured user /// diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs index 446c796c49..0f79aa707c 100644 --- a/cli/src/commands/diff.rs +++ b/cli/src/commands/diff.rs @@ -57,7 +57,10 @@ pub(crate) struct DiffArgs { #[arg(long, short, conflicts_with = "revision", add = ArgValueCandidates::new(complete::all_revisions))] to: Option, /// Restrict the diff to these paths - #[arg(value_hint = clap::ValueHint::AnyPath)] + #[arg( + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCandidates::new(complete::modified_revision_or_range_files), + )] paths: Vec, #[command(flatten)] format: DiffFormatArgs, diff --git a/cli/src/commands/file/annotate.rs b/cli/src/commands/file/annotate.rs index 2239debf68..a57f580752 100644 --- a/cli/src/commands/file/annotate.rs +++ b/cli/src/commands/file/annotate.rs @@ -13,6 +13,7 @@ // limitations under the License. use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::annotate::get_annotation_for_file; use jj_lib::annotate::FileAnnotation; use jj_lib::commit::Commit; @@ -37,7 +38,10 @@ use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] pub(crate) struct FileAnnotateArgs { /// the file to annotate - #[arg(value_hint = clap::ValueHint::AnyPath)] + #[arg( + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCompleter::new(complete::all_revision_files), + )] path: String, /// an optional revision to start at #[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))] diff --git a/cli/src/commands/file/chmod.rs b/cli/src/commands/file/chmod.rs index 19b6068cde..866a89cf2c 100644 --- a/cli/src/commands/file/chmod.rs +++ b/cli/src/commands/file/chmod.rs @@ -13,6 +13,7 @@ // limitations under the License. use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::backend::TreeValue; use jj_lib::merged_tree::MergedTreeBuilder; use jj_lib::object_id::ObjectId; @@ -52,7 +53,11 @@ pub(crate) struct FileChmodArgs { )] revision: RevisionArg, /// Paths to change the executable bit for - #[arg(required = true, value_hint = clap::ValueHint::AnyPath)] + #[arg( + required = true, + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCompleter::new(complete::all_revision_files), + )] paths: Vec, } diff --git a/cli/src/commands/file/show.rs b/cli/src/commands/file/show.rs index 396acac2e2..29057efde8 100644 --- a/cli/src/commands/file/show.rs +++ b/cli/src/commands/file/show.rs @@ -16,6 +16,7 @@ use std::io; use std::io::Write; use clap_complete::ArgValueCandidates; +use clap_complete::ArgValueCompleter; use jj_lib::backend::BackendResult; use jj_lib::conflicts::materialize_merge_result; use jj_lib::conflicts::materialize_tree_value; @@ -51,7 +52,11 @@ pub(crate) struct FileShowArgs { )] revision: RevisionArg, /// Paths to print - #[arg(required = true, value_hint = clap::ValueHint::FilePath)] + #[arg( + required = true, + value_hint = clap::ValueHint::FilePath, + add = ArgValueCompleter::new(complete::all_revision_files), + )] paths: Vec, } diff --git a/cli/src/commands/file/untrack.rs b/cli/src/commands/file/untrack.rs index b1afe6dba6..f2cff45fb0 100644 --- a/cli/src/commands/file/untrack.rs +++ b/cli/src/commands/file/untrack.rs @@ -14,6 +14,7 @@ use std::io::Write; +use clap_complete::ArgValueCompleter; use itertools::Itertools; use jj_lib::merge::Merge; use jj_lib::merged_tree::MergedTreeBuilder; @@ -24,6 +25,7 @@ use tracing::instrument; use crate::cli_util::CommandHelper; use crate::command_error::user_error_with_hint; use crate::command_error::CommandError; +use crate::complete; use crate::ui::Ui; /// Stop tracking specified paths in the working copy @@ -33,7 +35,11 @@ pub(crate) struct FileUntrackArgs { /// /// The paths could be ignored via a .gitignore or .git/info/exclude (in /// colocated repos). - #[arg(required = true, value_hint = clap::ValueHint::AnyPath)] + #[arg( + required = true, + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCompleter::new(complete::all_revision_files), + )] paths: Vec, } diff --git a/cli/src/commands/interdiff.rs b/cli/src/commands/interdiff.rs index 25d7e69abd..dbe2db66b3 100644 --- a/cli/src/commands/interdiff.rs +++ b/cli/src/commands/interdiff.rs @@ -42,7 +42,10 @@ pub(crate) struct InterdiffArgs { #[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))] to: Option, /// Restrict the diff to these paths - #[arg(value_hint = clap::ValueHint::AnyPath)] + #[arg( + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCandidates::new(complete::interdiff_files), + )] paths: Vec, #[command(flatten)] format: DiffFormatArgs, diff --git a/cli/src/commands/resolve.rs b/cli/src/commands/resolve.rs index 0f5d5a4160..76bd8315a3 100644 --- a/cli/src/commands/resolve.rs +++ b/cli/src/commands/resolve.rs @@ -62,7 +62,10 @@ pub(crate) struct ResolveArgs { /// will attempt to resolve the first conflict we can find. You can use /// the `--list` argument to find paths to use here. // TODO: Find the conflict we can resolve even if it's not the first one. - #[arg(value_hint = clap::ValueHint::AnyPath)] + #[arg( + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCandidates::new(complete::revision_conflicted_files), + )] paths: Vec, } diff --git a/cli/src/commands/restore.rs b/cli/src/commands/restore.rs index 9bc43c6a2a..11ec1bfdf7 100644 --- a/cli/src/commands/restore.rs +++ b/cli/src/commands/restore.rs @@ -45,7 +45,10 @@ use crate::ui::Ui; #[derive(clap::Args, Clone, Debug)] pub(crate) struct RestoreArgs { /// Restore only these paths (instead of all paths) - #[arg(value_hint = clap::ValueHint::AnyPath)] + #[arg( + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCandidates::new(complete::modified_range_files), + )] paths: Vec, /// Revision to restore from (source) #[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))] diff --git a/cli/src/commands/split.rs b/cli/src/commands/split.rs index 2ce219b303..e750af8705 100644 --- a/cli/src/commands/split.rs +++ b/cli/src/commands/split.rs @@ -66,7 +66,10 @@ pub(crate) struct SplitArgs { #[arg(long, short, alias = "siblings")] parallel: bool, /// Put these paths in the first commit - #[arg(value_hint = clap::ValueHint::AnyPath)] + #[arg( + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCandidates::new(complete::modified_revision_files), + )] paths: Vec, } diff --git a/cli/src/commands/squash.rs b/cli/src/commands/squash.rs index a32c353f5e..79f13fba31 100644 --- a/cli/src/commands/squash.rs +++ b/cli/src/commands/squash.rs @@ -90,7 +90,11 @@ pub(crate) struct SquashArgs { #[arg(long, value_name = "NAME")] tool: Option, /// Move only changes to these paths (instead of all paths) - #[arg(conflicts_with_all = ["interactive", "tool"], value_hint = clap::ValueHint::AnyPath)] + #[arg( + conflicts_with_all = ["interactive", "tool"], + value_hint = clap::ValueHint::AnyPath, + add = ArgValueCandidates::new(complete::squash_revision_files), + )] paths: Vec, /// The source revision will not be abandoned #[arg(long, short)] diff --git a/cli/src/complete.rs b/cli/src/complete.rs index 97795509a4..ed829a50b2 100644 --- a/cli/src/complete.rs +++ b/cli/src/complete.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::io::BufRead; + use clap::builder::StyledStr; use clap::FromArgMatches as _; use clap_complete::CompletionCandidate; @@ -446,6 +448,137 @@ pub fn leaf_config_keys() -> Vec { config_keys_impl(true) } +fn all_files_from_rev(rev: String) -> Vec { + with_jj(|jj, _| { + let mut child = jj + .build() + .arg("file") + .arg("list") + .arg("--revision") + .arg(rev) + .stdout(std::process::Stdio::piped()) + .spawn() + .map_err(user_error)?; + let stdout = child.stdout.take().unwrap(); + + Ok(std::io::BufReader::new(stdout) + .lines() + .take(1_000) + .map_while(Result::ok) + .map(CompletionCandidate::new) + .collect()) + }) +} + +fn modified_files_from_rev_with_jj_cmd( + rev: (String, Option), + mut cmd: std::process::Command, +) -> Result, CommandError> { + cmd.arg("diff").arg("--summary"); + match rev { + (rev, None) => cmd.arg("--revision").arg(rev), + (from, Some(to)) => cmd.arg("--from").arg(from).arg("--to").arg(to), + }; + let output = cmd.output().map_err(user_error)?; + let stdout = String::from_utf8_lossy(&output.stdout); + + Ok(stdout + .lines() + .map(|line| { + let (mode, path) = line + .split_once(' ') + .expect("diff --summary should contain a space between mode and path"); + let help = match mode { + "M" => "Modified".into(), + "D" => "Deleted".into(), + "A" => "Added".into(), + "R" => "Renamed".into(), + "C" => "Copied".into(), + _ => format!("unknown mode: '{mode}'"), + }; + CompletionCandidate::new(path).help(Some(help.into())) + }) + .collect()) +} + +fn modified_files_from_rev(rev: (String, Option)) -> Vec { + with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build())) +} + +fn conflicted_files_from_rev(rev: &str) -> Vec { + with_jj(|jj, _| { + let output = jj + .build() + .arg("resolve") + .arg("--list") + .arg("--revision") + .arg(rev) + .output() + .map_err(user_error)?; + let stdout = String::from_utf8_lossy(&output.stdout); + + Ok(stdout + .lines() + .filter_map(|line| line.split_whitespace().next()) + .map(CompletionCandidate::new) + .collect()) + }) +} + +pub fn modified_files() -> Vec { + modified_files_from_rev(("@".into(), None)) +} + +pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec { + // TODO: Use `current` once `jj file list` gains the ability to list only + // the content of the "current" directory. + let _ = current; + all_files_from_rev(parse::revision_or_wc()) +} + +pub fn modified_revision_files() -> Vec { + modified_files_from_rev((parse::revision_or_wc(), None)) +} + +pub fn modified_range_files() -> Vec { + match parse::range() { + Some((from, to)) => modified_files_from_rev((from, Some(to))), + None => modified_files_from_rev(("@".into(), None)), + } +} + +pub fn modified_revision_or_range_files() -> Vec { + if let Some(rev) = parse::revision() { + return modified_files_from_rev((rev, None)); + } + modified_range_files() +} + +pub fn revision_conflicted_files() -> Vec { + conflicted_files_from_rev(&parse::revision_or_wc()) +} + +/// Specific function for completing file paths for `jj squash` +pub fn squash_revision_files() -> Vec { + let rev = parse::squash_revision().unwrap_or_else(|| "@".into()); + modified_files_from_rev((rev, None)) +} + +/// Specific function for completing file paths for `jj interdiff` +pub fn interdiff_files() -> Vec { + let Some((from, to)) = parse::range() else { + return Vec::new(); + }; + // Complete all modified files in "from" and "to". This will also suggest + // files that are the same in both, which is a false positive. This approach + // is more lightweight than actually doing a temporary rebase here. + with_jj(|jj, _| { + let mut res = modified_files_from_rev_with_jj_cmd((from, None), jj.build())?; + res.extend(modified_files_from_rev_with_jj_cmd((to, None), jj.build())?); + Ok(res) + }) +} + /// Shell out to jj during dynamic completion generation /// /// In case of errors, print them and early return an empty vector. @@ -577,6 +710,81 @@ impl JjBuilder { } } +/// Functions for parsing revisions and revision ranges from the command line. +/// Parsing is done on a best-effort basis and relies on the heuristic that +/// most command line flags are consistent across different subcommands. +/// +/// In some cases, this parsing will be incorrect, but it's not worth the effort +/// to fix that. For example, if the user specifies any of the relevant flags +/// multiple times, the parsing will pick any of the available ones, while the +/// actual execution of the command would fail. +mod parse { + fn parse_flag(candidates: &[&str], mut args: impl Iterator) -> Option { + for arg in args.by_ref() { + // -r REV syntax + if candidates.contains(&arg.as_ref()) { + match args.next() { + Some(val) if !val.starts_with('-') => return Some(val), + _ => return None, + } + } + + // -r=REV syntax + if let Some(value) = candidates.iter().find_map(|candidate| { + let rest = arg.strip_prefix(candidate)?; + match rest.strip_prefix('=') { + Some(value) => Some(value), + + // -rREV syntax + None if candidate.len() == 2 => Some(rest), + + None => None, + } + }) { + return Some(value.into()); + }; + } + None + } + + pub fn parse_revision_impl(args: impl Iterator) -> Option { + parse_flag(&["-r", "--revision"], args) + } + + pub fn revision() -> Option { + parse_revision_impl(std::env::args()) + } + + pub fn revision_or_wc() -> String { + revision().unwrap_or_else(|| "@".into()) + } + + pub fn parse_range_impl(args: impl Fn() -> T) -> Option<(String, String)> + where + T: Iterator, + { + let from = parse_flag(&["-f", "--from"], args())?; + let to = parse_flag(&["-t", "--to"], args()).unwrap_or_else(|| "@".into()); + + Some((from, to)) + } + + pub fn range() -> Option<(String, String)> { + parse_range_impl(std::env::args) + } + + // Special parse function only for `jj squash`. While squash has --from and + // --to arguments, only files within --from should be completed, because + // the files changed only in some other revision in the range between + // --from and --to cannot be squashed into --to like that. + pub fn squash_revision() -> Option { + if let Some(rev) = parse_flag(&["-r", "--revision"], std::env::args()) { + return Some(rev); + } + parse_flag(&["-f", "--from"], std::env::args()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -586,4 +794,71 @@ mod tests { // Just make sure the schema is parsed without failure. let _ = config_keys(); } + + #[test] + fn test_parse_revision_impl() { + let good_cases: &[&[&str]] = &[ + &["-r", "foo"], + &["--revision", "foo"], + &["-r=foo"], + &["--revision=foo"], + &["preceding_arg", "-r", "foo"], + &["-r", "foo", "following_arg"], + ]; + for case in good_cases { + let args = case.iter().map(|s| s.to_string()); + assert_eq!( + parse::parse_revision_impl(args), + Some("foo".into()), + "case: {case:?}", + ); + } + let bad_cases: &[&[&str]] = &[&[], &["-r"], &["foo"], &["-R", "foo"], &["-R=foo"]]; + for case in bad_cases { + let args = case.iter().map(|s| s.to_string()); + assert_eq!(parse::parse_revision_impl(args), None, "case: {case:?}"); + } + } + + #[test] + fn test_parse_range_impl() { + let wc_cases: &[&[&str]] = &[ + &["-f", "foo"], + &["--from", "foo"], + &["-f=foo"], + &["preceding_arg", "-f", "foo"], + &["-f", "foo", "following_arg"], + ]; + for case in wc_cases { + let args = case.iter().map(|s| s.to_string()); + assert_eq!( + parse::parse_range_impl(|| args.clone()), + Some(("foo".into(), "@".into())), + "case: {case:?}", + ); + } + let to_cases: &[&[&str]] = &[ + &["-f", "foo", "-t", "bar"], + &["-f", "foo", "--to", "bar"], + &["-f=foo", "-t=bar"], + &["-t=bar", "-f=foo"], + ]; + for case in to_cases { + let args = case.iter().map(|s| s.to_string()); + assert_eq!( + parse::parse_range_impl(|| args.clone()), + Some(("foo".into(), "bar".into())), + "case: {case:?}", + ); + } + let bad_cases: &[&[&str]] = &[&[], &["-f"], &["foo"], &["-R", "foo"], &["-R=foo"]]; + for case in bad_cases { + let args = case.iter().map(|s| s.to_string()); + assert_eq!( + parse::parse_range_impl(|| args.clone()), + None, + "case: {case:?}" + ); + } + } } diff --git a/cli/tests/test_completion.rs b/cli/tests/test_completion.rs index 617a60b98c..409a3f691f 100644 --- a/cli/tests/test_completion.rs +++ b/cli/tests/test_completion.rs @@ -534,3 +534,215 @@ fn test_config() { core.watchman.register_snapshot_trigger Whether to use triggers to monitor for changes in the background. "); } + +fn create_commit( + test_env: &TestEnvironment, + repo_path: &std::path::Path, + name: &str, + parents: &[&str], + files: &[(&str, Option<&str>)], +) { + if parents.is_empty() { + test_env.jj_cmd_ok(repo_path, &["new", "root()", "-m", name]); + } else { + let mut args = vec!["new", "-m", name]; + args.extend(parents); + test_env.jj_cmd_ok(repo_path, &args); + } + for (name, content) in files { + match content { + Some(content) => std::fs::write(repo_path.join(name), content).unwrap(), + None => std::fs::remove_file(repo_path.join(name)).unwrap(), + } + } + test_env.jj_cmd_ok(repo_path, &["bookmark", "create", name]); +} + +#[test] +fn test_files() { + 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"); + + create_commit( + &test_env, + &repo_path, + "first", + &[], + &[ + ("f_unchanged", Some("unchanged\n")), + ("f_modified", Some("not_yet_modified\n")), + ("f_not_yet_renamed", Some("renamed\n")), + ("f_deleted", Some("not_yet_deleted\n")), + // not yet: "added" file + ], + ); + create_commit( + &test_env, + &repo_path, + "second", + &["first"], + &[ + // "unchanged" file + ("f_modified", Some("modified\n")), + ("f_renamed", Some("renamed\n")), + ("f_deleted", None), + ("f_added", Some("added\n")), + ], + ); + + // create a conflicted commit to check the completions of `jj restore` + create_commit( + &test_env, + &repo_path, + "conflicted", + &["second"], + &[ + ("f_modified", Some("modified_again\n")), + ("f_added_2", Some("added_2\n")), + ], + ); + test_env.jj_cmd_ok(&repo_path, &["rebase", "-r=@", "-d=first"]); + + // two commits that are similar but not identical, for `jj interdiff` + create_commit( + &test_env, + &repo_path, + "interdiff_from", + &[], + &[ + ("f_interdiff_same", Some("same in both commits\n")), + (("f_interdiff_only_from"), Some("only from\n")), + ], + ); + create_commit( + &test_env, + &repo_path, + "interdiff_to", + &[], + &[ + ("f_interdiff_same", Some("same in both commits\n")), + (("f_interdiff_only_to"), Some("only to\n")), + ], + ); + + // "dirty worktree" + create_commit( + &test_env, + &repo_path, + "working_copy", + &["second"], + &[ + ("f_modified", Some("modified_again\n")), + ("f_added_2", Some("added_2\n")), + ], + ); + + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-r", "all()", "--summary"]); + insta::assert_snapshot!(stdout, @r" + @ wqnwkozp test.user@example.com 2001-02-03 08:05:20 working_copy 89d772f3 + │ working_copy + │ A f_added_2 + │ M f_modified + ○ zsuskuln test.user@example.com 2001-02-03 08:05:11 second 12ffc2f7 + │ second + │ A f_added + │ D f_deleted + │ M f_modified + │ A f_renamed + │ × royxmykx test.user@example.com 2001-02-03 08:05:14 conflicted 14453858 conflict + ├─╯ conflicted + │ A f_added_2 + │ M f_modified + ○ rlvkpnrz test.user@example.com 2001-02-03 08:05:09 first 2a2f433c + │ first + │ A f_deleted + │ A f_modified + │ A f_not_yet_renamed + │ A f_unchanged + │ ○ kpqxywon test.user@example.com 2001-02-03 08:05:18 interdiff_to 302c4041 + ├─╯ interdiff_to + │ A f_interdiff_only_to + │ A f_interdiff_same + │ ○ yostqsxw test.user@example.com 2001-02-03 08:05:16 interdiff_from 083d1cc6 + ├─╯ interdiff_from + │ A f_interdiff_only_from + │ A f_interdiff_same + ◆ zzzzzzzz root() 00000000 + "); + + let mut test_env = test_env; + test_env.add_env_var("COMPLETE", "fish"); + let test_env = test_env; + + let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "file", "show", "f_"]); + insta::assert_snapshot!(stdout, @r" + f_added + f_added_2 + f_modified + f_not_yet_renamed + f_renamed + f_unchanged + "); + + let stdout = + test_env.jj_cmd_success(&repo_path, &["--", "jj", "file", "annotate", "-r@-", "f_"]); + insta::assert_snapshot!(stdout, @r" + f_added + f_modified + f_not_yet_renamed + f_renamed + f_unchanged + "); + + let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "diff", "-r", "@-", "f_"]); + insta::assert_snapshot!(stdout, @r" + f_added Added + f_deleted Deleted + f_modified Modified + f_renamed Added + "); + let stdout = test_env.jj_cmd_success( + &repo_path, + &["--", "jj", "diff", "--from", "root()", "--to", "@-", "f_"], + ); + insta::assert_snapshot!(stdout, @r" + f_added Added + f_modified Added + f_not_yet_renamed Added + f_renamed Added + f_unchanged Added + "); + + // interdiff has a different behavior with --from and --to flags + let stdout = test_env.jj_cmd_success( + &repo_path, + &[ + "--", + "jj", + "interdiff", + "--to=interdiff_to", + "--from=interdiff_from", + "f_", + ], + ); + insta::assert_snapshot!(stdout, @r" + f_interdiff_only_from Added + f_interdiff_same Added + f_interdiff_only_to Added + f_interdiff_same Added + "); + + // squash has a different behavior with --from and --to flags + let stdout = test_env.jj_cmd_success(&repo_path, &["--", "jj", "squash", "-f=first", "f_"]); + insta::assert_snapshot!(stdout, @r" + f_deleted Added + f_modified Added + f_not_yet_renamed Added + f_unchanged Added + "); + + let stdout = + test_env.jj_cmd_success(&repo_path, &["--", "jj", "resolve", "-r=conflicted", "f_"]); + insta::assert_snapshot!(stdout, @"f_modified"); +}