diff --git a/lib/src/working_copy.rs b/lib/src/working_copy.rs index b052e3122..b01d15891 100644 --- a/lib/src/working_copy.rs +++ b/lib/src/working_copy.rs @@ -38,7 +38,7 @@ use crate::commit::Commit; use crate::conflicts::{materialize_conflict, update_conflict_from_content}; use crate::gitignore::GitIgnoreFile; use crate::lock::FileLock; -use crate::matchers::EverythingMatcher; +use crate::matchers::{EverythingMatcher, Matcher}; use crate::repo_path::{RepoPath, RepoPathComponent, RepoPathJoin}; use crate::store::Store; use crate::tree::Diff; @@ -189,6 +189,16 @@ pub enum CheckoutError { InternalBackendError(BackendError), } +#[derive(Debug, Error, PartialEq, Eq)] +pub enum UntrackError { + // The current checkout was deleted, maybe by an overly aggressive GC that happened while + // the current process was running. + #[error("Current checkout not found")] + SourceNotFound, + #[error("Internal error: {0:?}")] + InternalBackendError(BackendError), +} + impl TreeState { pub fn current_tree_id(&self) -> &TreeId { &self.tree_id @@ -662,6 +672,25 @@ impl TreeState { self.save(); Ok(stats) } + + pub fn untrack(&mut self, matcher: &dyn Matcher) -> Result<(), UntrackError> { + let tree = self + .store + .get_tree(&RepoPath::root(), &self.tree_id) + .map_err(|err| match err { + BackendError::NotFound => UntrackError::SourceNotFound, + other => UntrackError::InternalBackendError(other), + })?; + + let mut tree_builder = self.store.tree_builder(self.tree_id.clone()); + for (path, _value) in tree.entries_matching(matcher) { + self.file_states.remove(&path); + tree_builder.remove(path); + } + self.tree_id = tree_builder.write_tree(); + self.save(); + Ok(()) + } } pub struct WorkingCopy { @@ -831,6 +860,19 @@ impl WorkingCopy { closed: false, } } + + pub fn untrack(&self, matcher: &dyn Matcher) -> Result { + let lock_path = self.state_path.join("working_copy.lock"); + let lock = FileLock::lock(lock_path); + + self.tree_state().as_mut().unwrap().untrack(matcher)?; + + Ok(LockedWorkingCopy { + wc: self, + lock, + closed: false, + }) + } } // A working copy that's locked on disk. The tree state has already been diff --git a/lib/tests/test_working_copy.rs b/lib/tests/test_working_copy.rs index f7371df39..d7432a91d 100644 --- a/lib/tests/test_working_copy.rs +++ b/lib/tests/test_working_copy.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::HashSet; use std::fs::{File, OpenOptions}; use std::io::{Read, Write}; #[cfg(unix)] @@ -21,6 +22,7 @@ use std::sync::Arc; use itertools::Itertools; use jujutsu_lib::backend::{Conflict, ConflictPart, TreeValue}; use jujutsu_lib::commit_builder::CommitBuilder; +use jujutsu_lib::matchers::FilesMatcher; use jujutsu_lib::repo::ReadonlyRepo; use jujutsu_lib::repo_path::{RepoPath, RepoPathComponent}; use jujutsu_lib::settings::UserSettings; @@ -294,6 +296,68 @@ fn test_checkout_file_transitions(use_git: bool) { } } +#[test] +fn test_untrack() { + let settings = testutils::user_settings(); + let (_temp_dir, repo) = testutils::init_repo(&settings, false); + + let wanted_path = RepoPath::from_internal_string("wanted"); + let unwanted_path = RepoPath::from_internal_string("unwanted"); + let gitignore_path = RepoPath::from_internal_string(".gitignore"); + + // First create a commit where one of the files is unwanted. + let initial_tree = testutils::create_tree( + &repo, + &[ + (&wanted_path, "code"), + (&unwanted_path, "garbage"), + (&gitignore_path, "unwanted\n"), + ], + ); + let mut tx = repo.start_transaction("test"); + let initial_commit = CommitBuilder::for_open_commit( + &settings, + repo.store(), + repo.store().root_commit_id().clone(), + initial_tree.id().clone(), + ) + .write_to_repo(tx.mut_repo()); + let repo = tx.commit(); + let working_copy = repo.working_copy().clone(); + let locked_working_copy = working_copy.lock().unwrap(); + locked_working_copy + .check_out(initial_commit.clone()) + .unwrap(); + + // Now we untrack the file called "unwanted" + let mut tx = repo.start_transaction("test"); + let matcher = FilesMatcher::new(HashSet::from([unwanted_path.clone()])); + let unfinished_write = locked_working_copy.untrack(&matcher).unwrap(); + let new_commit = CommitBuilder::for_rewrite_from(&settings, repo.store(), &initial_commit) + .set_tree(unfinished_write.new_tree_id()) + .write_to_repo(tx.mut_repo()); + unfinished_write.finish(new_commit.clone()); + let repo = tx.commit(); + + // The file should still exist in the working copy. + assert!(unwanted_path.to_fs_path(repo.working_copy_path()).is_file()); + + // It should not be in the new tree. + let tracked_paths = new_commit + .tree() + .entries() + .map(|(path, _)| path) + .collect_vec(); + assert_eq!(tracked_paths, vec![gitignore_path, wanted_path]); + + // It should not get re-added if we write a new tree since we also added it + // to the .gitignore file. + let unfinished_write = locked_working_copy.write_tree(); + let new_tree_id = unfinished_write.new_tree_id(); + assert_eq!(new_tree_id, *new_commit.tree().id()); + unfinished_write.discard(); +} + #[test_case(false ; "local backend")] #[test_case(true ; "git backend")] fn test_commit_racy_timestamps(use_git: bool) {