completion: teach commands about files
Some checks are pending
binaries / Build binary artifacts (push) Waiting to run
nix / flake check (push) Waiting to run
build / build (, macos-13) (push) Waiting to run
build / build (, macos-14) (push) Waiting to run
build / build (, ubuntu-latest) (push) Waiting to run
build / build (, windows-latest) (push) Waiting to run
build / build (--all-features, ubuntu-latest) (push) Waiting to run
build / Build jj-lib without Git support (push) Waiting to run
build / Check protos (push) Waiting to run
build / Check formatting (push) Waiting to run
build / Check that MkDocs can build the docs (push) Waiting to run
build / Check that MkDocs can build the docs with latest Python and uv (push) Waiting to run
build / cargo-deny (advisories) (push) Waiting to run
build / cargo-deny (bans licenses sources) (push) Waiting to run
build / Clippy check (push) Waiting to run
Codespell / Codespell (push) Waiting to run
website / prerelease-docs-build-deploy (ubuntu-latest) (push) Waiting to run
Scorecards supply-chain security / Scorecards analysis (push) Waiting to run

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.
This commit is contained in:
Remo Senekowitsch 2024-11-16 10:30:42 +01:00
parent a5690beab5
commit 5fcc549eab
13 changed files with 542 additions and 11 deletions

View file

@ -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<String>,
/// 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<String>,
/// Reset the author to the configured user
///

View file

@ -57,7 +57,10 @@ pub(crate) struct DiffArgs {
#[arg(long, short, conflicts_with = "revision", add = ArgValueCandidates::new(complete::all_revisions))]
to: Option<RevisionArg>,
/// 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<String>,
#[command(flatten)]
format: DiffFormatArgs,

View file

@ -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))]

View file

@ -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<String>,
}

View file

@ -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<String>,
}

View file

@ -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<String>,
}

View file

@ -42,7 +42,10 @@ pub(crate) struct InterdiffArgs {
#[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))]
to: Option<RevisionArg>,
/// 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<String>,
#[command(flatten)]
format: DiffFormatArgs,

View file

@ -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<String>,
}

View file

@ -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<String>,
/// Revision to restore from (source)
#[arg(long, short, add = ArgValueCandidates::new(complete::all_revisions))]

View file

@ -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<String>,
}

View file

@ -90,7 +90,11 @@ pub(crate) struct SquashArgs {
#[arg(long, value_name = "NAME")]
tool: Option<String>,
/// 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<String>,
/// The source revision will not be abandoned
#[arg(long, short)]

View file

@ -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<CompletionCandidate> {
config_keys_impl(true)
}
fn all_files_from_rev(rev: String) -> Vec<CompletionCandidate> {
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<String>),
mut cmd: std::process::Command,
) -> Result<Vec<CompletionCandidate>, 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<String>)) -> Vec<CompletionCandidate> {
with_jj(|jj, _| modified_files_from_rev_with_jj_cmd(rev, jj.build()))
}
fn conflicted_files_from_rev(rev: &str) -> Vec<CompletionCandidate> {
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<CompletionCandidate> {
modified_files_from_rev(("@".into(), None))
}
pub fn all_revision_files(current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
// 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<CompletionCandidate> {
modified_files_from_rev((parse::revision_or_wc(), None))
}
pub fn modified_range_files() -> Vec<CompletionCandidate> {
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<CompletionCandidate> {
if let Some(rev) = parse::revision() {
return modified_files_from_rev((rev, None));
}
modified_range_files()
}
pub fn revision_conflicted_files() -> Vec<CompletionCandidate> {
conflicted_files_from_rev(&parse::revision_or_wc())
}
/// Specific function for completing file paths for `jj squash`
pub fn squash_revision_files() -> Vec<CompletionCandidate> {
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<CompletionCandidate> {
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<Item = String>) -> Option<String> {
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<Item = String>) -> Option<String> {
parse_flag(&["-r", "--revision"], args)
}
pub fn revision() -> Option<String> {
parse_revision_impl(std::env::args())
}
pub fn revision_or_wc() -> String {
revision().unwrap_or_else(|| "@".into())
}
pub fn parse_range_impl<T>(args: impl Fn() -> T) -> Option<(String, String)>
where
T: Iterator<Item = String>,
{
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<String> {
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:?}"
);
}
}
}

View file

@ -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");
}