diff --git a/CHANGELOG.md b/CHANGELOG.md index 408c8a47efe..9ff9a701761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New features +* `jj sparse set --stdin` now replaces the working copy's sparse patterns with + newline-separated, workspace-relative paths read from standard input. + * `jj bisect` will now mention when it cannot unambiguously find the first bad revision due to skips in evaluation. diff --git a/cli/src/commands/sparse/set.rs b/cli/src/commands/sparse/set.rs index 9e581845df0..8290d02425f 100644 --- a/cli/src/commands/sparse/set.rs +++ b/cli/src/commands/sparse/set.rs @@ -13,6 +13,8 @@ // limitations under the License. use std::collections::HashSet; +use std::io; +use std::io::Read as _; use itertools::Itertools as _; use jj_lib::repo_path::RepoPathBuf; @@ -21,6 +23,7 @@ use tracing::instrument; use super::update_sparse_patterns_with; use crate::cli_util::CommandHelper; use crate::command_error::CommandError; +use crate::command_error::user_error_with_message; use crate::ui::Ui; /// Update the patterns that are present in the working copy @@ -50,6 +53,10 @@ pub struct SparseSetArgs { /// Include no files in the working copy (combine with --add) #[arg(long)] clear: bool, + + /// Read the replacement sparse patterns from stdin, one path per line + #[arg(long)] + stdin: bool, } #[instrument(skip_all)] @@ -58,14 +65,31 @@ pub async fn cmd_sparse_set( command: &CommandHelper, args: &SparseSetArgs, ) -> Result<(), CommandError> { + let stdin_patterns = if args.stdin { + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + input + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + RepoPathBuf::from_relative_path(line).map_err(|err| { + user_error_with_message(format!("Failed to parse sparse pattern: {line}"), err) + }) + }) + .try_collect::<_, Vec<_>, _>()? + } else { + Vec::new() + }; + let mut workspace_command = command.workspace_helper(ui).await?; update_sparse_patterns_with(ui, &mut workspace_command, |_ui, old_patterns| { let mut new_patterns = HashSet::new(); - if !args.clear { + if !args.clear && !args.stdin { new_patterns.extend(old_patterns.iter().cloned()); - for path in &args.remove { - new_patterns.remove(path); - } + } + new_patterns.extend(stdin_patterns); + for path in &args.remove { + new_patterns.remove(path); } for path in &args.add { new_patterns.insert(path.to_owned()); diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index d03d1c88b3b..08bc454f715 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -3181,6 +3181,7 @@ For example, if all you need is the `README.md` and the `lib/` directory, use `j * `--add ` — Patterns to add to the working copy * `--remove ` — Patterns to remove from the working copy * `--clear` — Include no files in the working copy (combine with --add) +* `--stdin` — Read the replacement sparse patterns from stdin, one path per line diff --git a/cli/tests/test_sparse_command.rs b/cli/tests/test_sparse_command.rs index 7ac469c3f24..4a55d6dba03 100644 --- a/cli/tests/test_sparse_command.rs +++ b/cli/tests/test_sparse_command.rs @@ -195,6 +195,105 @@ fn test_sparse_manage_patterns() { "#); } +#[test] +fn test_sparse_set_from_stdin_replaces_patterns() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + work_dir.write_file("file1", "contents"); + work_dir.write_file("file2", "contents"); + work_dir.write_file("file3", "contents"); + + work_dir + .run_jj_with(|cmd| { + cmd.args(["sparse", "set", "--stdin"]) + .write_stdin("file3\nfile1\nfile3\n") + }) + .success(); + + insta::assert_snapshot!(work_dir.run_jj(["sparse", "list"]), @" + file1 + file3 + [EOF] + "); + assert!(work_dir.root().join("file1").exists()); + assert!(!work_dir.root().join("file2").exists()); + assert!(work_dir.root().join("file3").exists()); +} + +#[test] +fn test_sparse_set_from_stdin_combines_command_line_paths() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + work_dir.write_file("file1", "contents"); + work_dir.write_file("directory/file with spaces", "contents"); + work_dir.write_file("excluded", "contents"); + + work_dir + .run_jj_with(|cmd| { + cmd.args([ + "sparse", "set", "--stdin", "--add", "file1", "--remove", "excluded", + ]) + .write_stdin("\ndirectory/file with spaces\r\nexcluded\n\n") + }) + .success(); + + insta::assert_snapshot!(work_dir.run_jj(["sparse", "list"]).normalize_backslash(), @" + directory/file with spaces + file1 + [EOF] + "); + assert!(work_dir.root().join("directory/file with spaces").exists()); + assert!(!work_dir.root().join("excluded").exists()); +} + +#[test] +fn test_sparse_set_from_empty_stdin_clears_patterns() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + work_dir.write_file("file1", "contents"); + + // Git's `sparse-checkout set --stdin` also clears all patterns on empty input. + work_dir + .run_jj_with(|cmd| cmd.args(["sparse", "set", "--stdin"]).write_stdin("")) + .success(); + + insta::assert_snapshot!(work_dir.run_jj(["sparse", "list"]), @""); + assert!(!work_dir.root().join("file1").exists()); + insta::assert_snapshot!(work_dir.run_jj(["file", "list"]), @" + file1 + [EOF] + "); +} + +#[test] +fn test_sparse_set_from_stdin_rejects_invalid_paths_without_changing_patterns() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + work_dir.write_file("file1", "contents"); + + let output = work_dir.run_jj_with(|cmd| { + cmd.args(["sparse", "set", "--stdin"]) + .write_stdin("file1\n../outside\n") + }); + + insta::assert_snapshot!(output, @r#" + ------- stderr ------- + Error: Failed to parse sparse pattern: ../outside + Caused by: Invalid component ".." in repo-relative path "../outside" + [EOF] + [exit status: 1] + "#); + insta::assert_snapshot!(work_dir.run_jj(["sparse", "list"]), @" + . + [EOF] + "); + assert!(work_dir.root().join("file1").exists()); +} + #[test] fn test_sparse_editor_avoids_unc() -> TestResult { use std::path::PathBuf;