diff --git a/CHANGELOG.md b/CHANGELOG.md index 726886053..95c1fb277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `jj split` now lets you specify on the CLI which paths to include in the first commit. The interactive diff-editing is not started when you do that. +* Sparse checkouts are now supported. In fact, all working copies are now + "sparse", only to different degrees. Use the `jj sparse` command to manage + the paths included in the sparse checkout. + * The `$JJ_CONFIG` environment variable can now point to a directory. If it does, all files in the directory will be read, in alphabetical order. diff --git a/docs/git-compatibility.md b/docs/git-compatibility.md index 73a0ce64b..a7de3885e 100644 --- a/docs/git-compatibility.md +++ b/docs/git-compatibility.md @@ -57,9 +57,8 @@ a comparison with Git, including how workflows are different, see the which doesn't have support for partial clones. * **git-worktree: No.** However, there's native support for multiple working copies backed by a single repo. See the `jj workspace` family of commands. -* **Sparse checkouts: No.** There is - [#52](https://github.com/martinvonz/jj/issues/52) about native support for - sparse checkouts. +* **Sparse checkouts: No.** However, there's native support for sparse + checkouts. See the `jj sparse` command. * **Signed commits: No.** ([#58](https://github.com/martinvonz/jj/issues/58)) * **Git LFS: No.** ([#80](https://github.com/martinvonz/jj/issues/80)) diff --git a/src/commands.rs b/src/commands.rs index 079bc25d1..194a7f5c5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -780,13 +780,7 @@ impl WorkspaceCommandHelper { maybe_old_commit.as_ref(), )?; if let Some(stats) = stats { - if stats.added_files > 0 || stats.updated_files > 0 || stats.removed_files > 0 { - writeln!( - ui, - "Added {} files, modified {} files, removed {} files", - stats.added_files, stats.updated_files, stats.removed_files - )?; - } + print_checkout_stats(ui, stats)?; } } if self.working_copy_shared_with_git { @@ -797,6 +791,17 @@ impl WorkspaceCommandHelper { } } +fn print_checkout_stats(ui: &mut Ui, stats: CheckoutStats) -> Result<(), std::io::Error> { + if stats.added_files > 0 || stats.updated_files > 0 || stats.removed_files > 0 { + writeln!( + ui, + "Added {} files, modified {} files, removed {} files", + stats.added_files, stats.updated_files, stats.removed_files + )?; + } + Ok(()) +} + /// Expands "~/" to "$HOME/" as Git seems to do for e.g. core.excludesFile. fn expand_git_path(path_str: String) -> PathBuf { if let Some(remainder) = path_str.strip_prefix("~/") { @@ -919,11 +924,11 @@ fn resolve_single_op_from_store( } } -fn matcher_from_values( +fn repo_paths_from_values( ui: &Ui, wc_path: &Path, values: &[String], -) -> Result, CommandError> { +) -> Result, CommandError> { if !values.is_empty() { // TODO: Add support for globs and other formats let mut paths = vec![]; @@ -931,9 +936,22 @@ fn matcher_from_values( let repo_path = ui.parse_file_path(wc_path, value)?; paths.push(repo_path); } - Ok(Box::new(PrefixMatcher::new(&paths))) + Ok(paths) } else { + Ok(vec![]) + } +} + +fn matcher_from_values( + ui: &Ui, + wc_path: &Path, + values: &[String], +) -> Result, CommandError> { + let paths = repo_paths_from_values(ui, wc_path, values)?; + if paths.is_empty() { Ok(Box::new(EverythingMatcher)) + } else { + Ok(Box::new(PrefixMatcher::new(&paths))) } } @@ -1072,6 +1090,7 @@ enum Commands { Undo(OperationUndoArgs), Operation(OperationArgs), Workspace(WorkspaceArgs), + Sparse(SparseArgs), Git(GitArgs), Bench(BenchArgs), Debug(DebugArgs), @@ -1649,6 +1668,26 @@ struct WorkspaceForgetArgs { #[derive(clap::Args, Clone, Debug)] struct WorkspaceListArgs {} +/// Manage which paths from the current checkout are present in the working copy +#[derive(clap::Args, Clone, Debug)] +struct SparseArgs { + /// Patterns to add to the working copy + #[clap(long)] + add: Vec, + /// Patterns to remove from the working copy + #[clap(long, conflicts_with = "clear")] + remove: Vec, + /// Include no files in the working copy (combine with --add) + #[clap(long)] + clear: bool, + /// Include all files in the working copy + #[clap(long, conflicts_with_all = &["add", "remove", "clear"])] + reset: bool, + /// List patterns + #[clap(long, conflicts_with_all = &["add", "remove", "clear", "reset"])] + list: bool, +} + /// Commands for working with the underlying Git repo /// /// For a comparison with Git, including a table of commands, see @@ -4465,6 +4504,45 @@ fn cmd_workspace_list( Ok(()) } +fn cmd_sparse(ui: &mut Ui, command: &CommandHelper, args: &SparseArgs) -> Result<(), CommandError> { + if args.list { + let workspace_command = command.workspace_helper(ui)?; + for path in workspace_command.working_copy().sparse_patterns() { + let ui_path = workspace_command.format_file_path(&path); + writeln!(ui, "{}", ui_path)?; + } + } else { + let mut workspace_command = command.workspace_helper(ui)?; + workspace_command.commit_working_copy(ui)?; + let workspace_root = workspace_command.workspace_root().clone(); + let paths_to_add = repo_paths_from_values(ui, &workspace_root, &args.add)?; + let (mut locked_wc, _current_checkout) = workspace_command.start_working_copy_mutation()?; + let mut new_patterns = HashSet::new(); + if args.reset { + new_patterns.insert(RepoPath::root()); + } else { + if !args.clear { + new_patterns.extend(locked_wc.sparse_patterns()); + let paths_to_remove = repo_paths_from_values(ui, &workspace_root, &args.remove)?; + for path in paths_to_remove { + new_patterns.remove(&path); + } + } + for path in paths_to_add { + new_patterns.insert(path); + } + } + let new_patterns = new_patterns.into_iter().sorted().collect(); + let stats = locked_wc.set_sparse_patterns(new_patterns).map_err(|err| { + CommandError::InternalError(format!("Failed to update working copy paths: {}", err)) + })?; + let operation_id = locked_wc.old_operation_id().clone(); + locked_wc.finish(operation_id); + print_checkout_stats(ui, stats)?; + } + Ok(()) +} + fn get_git_repo(store: &Store) -> Result { match store.git_repo() { None => Err(CommandError::UserError( @@ -4871,6 +4949,7 @@ where Commands::Undo(sub_args) => cmd_op_undo(&mut ui, &command_helper, sub_args), Commands::Operation(sub_args) => cmd_operation(&mut ui, &command_helper, sub_args), Commands::Workspace(sub_args) => cmd_workspace(&mut ui, &command_helper, sub_args), + Commands::Sparse(sub_args) => cmd_sparse(&mut ui, &command_helper, sub_args), Commands::Git(sub_args) => cmd_git(&mut ui, &command_helper, sub_args), Commands::Bench(sub_args) => cmd_bench(&mut ui, &command_helper, sub_args), Commands::Debug(sub_args) => cmd_debug(&mut ui, &command_helper, sub_args), diff --git a/tests/test_sparse_command.rs b/tests/test_sparse_command.rs new file mode 100644 index 000000000..2b447e4be --- /dev/null +++ b/tests/test_sparse_command.rs @@ -0,0 +1,105 @@ +// Copyright 2022 Google LLC +// +// 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 crate::common::TestEnvironment; + +pub mod common; + +#[test] +fn test_sparse_manage_patterns() { + 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"); + + // Write some files to the working copy + std::fs::write(repo_path.join("file1"), "contents").unwrap(); + std::fs::write(repo_path.join("file2"), "contents").unwrap(); + std::fs::write(repo_path.join("file3"), "contents").unwrap(); + + // By default, all files are tracked + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--list"]); + insta::assert_snapshot!(stdout, @". +"); + + // Can stop tracking all files + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--remove", "."]); + insta::assert_snapshot!(stdout, @"Added 0 files, modified 0 files, removed 3 files +"); + // The list is now empty + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--list"]); + insta::assert_snapshot!(stdout, @""); + // They're removed from the working copy + assert!(!repo_path.join("file1").exists()); + assert!(!repo_path.join("file2").exists()); + assert!(!repo_path.join("file3").exists()); + // But they're still in the commit + let stdout = test_env.jj_cmd_success(&repo_path, &["files"]); + insta::assert_snapshot!(stdout, @r###" + file1 + file2 + file3 + "###); + + // Can `--add` a few files + let stdout = + test_env.jj_cmd_success(&repo_path, &["sparse", "--add", "file2", "--add", "file3"]); + insta::assert_snapshot!(stdout, @"Added 2 files, modified 0 files, removed 0 files +"); + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--list"]); + insta::assert_snapshot!(stdout, @r###" + file2 + file3 + "###); + assert!(!repo_path.join("file1").exists()); + assert!(repo_path.join("file2").exists()); + assert!(repo_path.join("file3").exists()); + + // Can combine `--add` and `--remove` + let stdout = test_env.jj_cmd_success( + &repo_path, + &[ + "sparse", "--add", "file1", "--remove", "file2", "--remove", "file3", + ], + ); + insta::assert_snapshot!(stdout, @"Added 1 files, modified 0 files, removed 2 files +"); + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--list"]); + insta::assert_snapshot!(stdout, @"file1 +"); + assert!(repo_path.join("file1").exists()); + assert!(!repo_path.join("file2").exists()); + assert!(!repo_path.join("file3").exists()); + + // Can use `--clear` and `--add` + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--clear", "--add", "file2"]); + insta::assert_snapshot!(stdout, @"Added 1 files, modified 0 files, removed 1 files +"); + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--list"]); + insta::assert_snapshot!(stdout, @"file2 +"); + assert!(!repo_path.join("file1").exists()); + assert!(repo_path.join("file2").exists()); + assert!(!repo_path.join("file3").exists()); + + // Can reset back to all files + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--reset"]); + insta::assert_snapshot!(stdout, @"Added 2 files, modified 0 files, removed 0 files +"); + let stdout = test_env.jj_cmd_success(&repo_path, &["sparse", "--list"]); + insta::assert_snapshot!(stdout, @". +"); + assert!(repo_path.join("file1").exists()); + assert!(repo_path.join("file2").exists()); + assert!(repo_path.join("file3").exists()); +} diff --git a/tests/test_untrack_command.rs b/tests/test_untrack_command.rs index f239065b5..6bc54ac95 100644 --- a/tests/test_untrack_command.rs +++ b/tests/test_untrack_command.rs @@ -92,3 +92,28 @@ fn test_untrack() { let files_after = test_env.jj_cmd_success(&repo_path, &["files"]); assert!(!files_after.contains("target")); } + +#[test] +fn test_untrack_sparse() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + std::fs::write(repo_path.join("file1"), "contents").unwrap(); + std::fs::write(repo_path.join("file2"), "contents").unwrap(); + + // When untracking a file that's not included in the sparse working copy, it + // doesn't need to be ignored (because it won't be automatically added + // back). + let stdout = test_env.jj_cmd_success(&repo_path, &["files"]); + insta::assert_snapshot!(stdout, @r###" + file1 + file2 + "###); + test_env.jj_cmd_success(&repo_path, &["sparse", "--clear", "--add", "file1"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["untrack", "file2"]); + insta::assert_snapshot!(stdout, @""); + let stdout = test_env.jj_cmd_success(&repo_path, &["files"]); + insta::assert_snapshot!(stdout, @"file1 +"); +}