// Copyright 2020 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 std::fs::{File, OpenOptions}; use std::io::{Read, Write}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use jujube_lib::commit_builder::CommitBuilder; use jujube_lib::repo::ReadonlyRepo; use jujube_lib::repo_path::{FileRepoPath, RepoPath}; use jujube_lib::settings::UserSettings; use jujube_lib::store::TreeValue; use jujube_lib::testutils; use jujube_lib::tree_builder::TreeBuilder; use test_case::test_case; #[test_case(false ; "local store")] #[test_case(true ; "git store")] fn test_root(use_git: bool) { let settings = testutils::user_settings(); let (_temp_dir, repo) = testutils::init_repo(&settings, use_git); let owned_wc = repo.working_copy().clone(); let wc = owned_wc.lock().unwrap(); assert_eq!(&wc.current_commit_id(), repo.view().checkout()); assert_ne!(&wc.current_commit_id(), repo.store().root_commit_id()); let (repo, wc_commit) = wc.commit(&settings, repo); assert_eq!(wc_commit.id(), repo.view().checkout()); assert_eq!(wc_commit.tree().id(), repo.store().empty_tree_id()); assert_eq!(wc_commit.store_commit().parents, vec![]); assert_eq!(wc_commit.predecessors(), vec![]); assert_eq!(wc_commit.description(), ""); assert!(wc_commit.is_open()); assert_eq!(wc_commit.author().name, settings.user_name()); assert_eq!(wc_commit.author().email, settings.user_email()); assert_eq!(wc_commit.committer().name, settings.user_name()); assert_eq!(wc_commit.committer().email, settings.user_email()); } #[cfg(unix)] #[test_case(false ; "local store")] #[test_case(true ; "git store")] fn test_checkout_file_transitions(use_git: bool) { // Tests switching between commits where a certain path is of one type in one // commit and another type in the other. Includes a "missing" type, so we cover // additions and removals as well. let settings = testutils::user_settings(); let (_temp_dir, mut repo) = testutils::init_repo(&settings, use_git); let store = repo.store().clone(); #[derive(Debug, Clone, Copy)] enum Kind { Missing, Normal, Executable, Symlink, Tree, GitSubmodule, } fn write_path( settings: &UserSettings, repo: &Arc, tree_builder: &mut TreeBuilder, kind: Kind, path: &str, ) { let store = repo.store(); let value = match kind { Kind::Missing => { return; } Kind::Normal => { let id = testutils::write_file(store, &FileRepoPath::from(path), "normal file contents"); TreeValue::Normal { id, executable: false, } } Kind::Executable => { let id = testutils::write_file( store, &FileRepoPath::from(path), "executable file contents", ); TreeValue::Normal { id, executable: true, } } Kind::Symlink => { let id = store .write_symlink(&FileRepoPath::from(path), "target") .unwrap(); TreeValue::Symlink(id) } Kind::Tree => { let mut sub_tree_builder = store.tree_builder(store.empty_tree_id().clone()); let file_path = path.to_owned() + "/file"; write_path( settings, repo, &mut sub_tree_builder, Kind::Normal, &file_path, ); let id = sub_tree_builder.write_tree(); TreeValue::Tree(id) } Kind::GitSubmodule => { let id = testutils::create_random_commit(&settings, &repo) .write_to_new_transaction(&repo, "test") .id() .clone(); TreeValue::GitSubmodule(id) } }; tree_builder.set(RepoPath::from(path), value); } let mut kinds = vec![ Kind::Missing, Kind::Normal, Kind::Executable, Kind::Symlink, Kind::Tree, ]; if use_git { kinds.push(Kind::GitSubmodule); } let mut left_tree_builder = store.tree_builder(store.empty_tree_id().clone()); let mut right_tree_builder = store.tree_builder(store.empty_tree_id().clone()); let mut files = vec![]; for left_kind in &kinds { for right_kind in &kinds { let path = format!("{:?}_{:?}", left_kind, right_kind); write_path(&settings, &repo, &mut left_tree_builder, *left_kind, &path); write_path( &settings, &repo, &mut right_tree_builder, *right_kind, &path, ); files.push((*left_kind, *right_kind, path)); } } let left_tree_id = left_tree_builder.write_tree(); let right_tree_id = right_tree_builder.write_tree(); let left_commit = CommitBuilder::for_new_commit(&settings, repo.store(), left_tree_id) .set_parents(vec![store.root_commit_id().clone()]) .set_open(true) .write_to_new_transaction(&repo, "test"); let right_commit = CommitBuilder::for_new_commit(&settings, repo.store(), right_tree_id) .set_parents(vec![store.root_commit_id().clone()]) .set_open(true) .write_to_new_transaction(&repo, "test"); let owned_wc = repo.working_copy().clone(); let wc = owned_wc.lock().unwrap(); wc.check_out(left_commit).unwrap(); repo = wc.commit(&settings, repo).0; wc.check_out(right_commit.clone()).unwrap(); // Check that the working copy is clean. let (reloaded_repo, after_commit) = wc.commit(&settings, repo); repo = reloaded_repo; let diff_summary = right_commit.tree().diff_summary(&after_commit.tree()); assert_eq!(diff_summary.modified, vec![]); assert_eq!(diff_summary.added, vec![]); assert_eq!(diff_summary.removed, vec![]); for (_left_kind, right_kind, path) in &files { let wc_path = repo.working_copy_path().join(path); let maybe_metadata = wc_path.symlink_metadata(); match right_kind { Kind::Missing => { assert!(!maybe_metadata.is_ok(), "{:?} should not exist", path); } Kind::Normal => { assert!(maybe_metadata.is_ok(), "{:?} should exist", path); let metadata = maybe_metadata.unwrap(); assert!(metadata.is_file(), "{:?} should be a file", path); #[cfg(unix)] assert_eq!( metadata.permissions().mode() & 0o111, 0, "{:?} should not be executable", path ); } Kind::Executable => { assert!(maybe_metadata.is_ok(), "{:?} should exist", path); let metadata = maybe_metadata.unwrap(); assert!(metadata.is_file(), "{:?} should be a file", path); #[cfg(unix)] assert_ne!( metadata.permissions().mode() & 0o111, 0, "{:?} should be executable", path ); } Kind::Symlink => { assert!(maybe_metadata.is_ok(), "{:?} should exist", path); let metadata = maybe_metadata.unwrap(); assert!( metadata.file_type().is_symlink(), "{:?} should be a symlink", path ); } Kind::Tree => { assert!(maybe_metadata.is_ok(), "{:?} should exist", path); let metadata = maybe_metadata.unwrap(); assert!(metadata.is_dir(), "{:?} should be a directory", path); } Kind::GitSubmodule => { // Not supported for now assert!(!maybe_metadata.is_ok(), "{:?} should not exist", path); } }; } } #[test_case(false ; "local store")] #[test_case(true ; "git store")] fn test_commit_racy_timestamps(use_git: bool) { // Tests that file modifications are detected even if they happen the same // millisecond as the updated working copy state. let _home_dir = testutils::new_user_home(); let settings = testutils::user_settings(); let (_temp_dir, mut repo) = testutils::init_repo(&settings, use_git); let file_path = repo.working_copy_path().join("file"); let mut previous_tree_id = repo.store().empty_tree_id().clone(); let owned_wc = repo.working_copy().clone(); let wc = owned_wc.lock().unwrap(); for i in 0..100 { { let mut file = OpenOptions::new() .create(true) .write(true) .open(&file_path) .unwrap(); file.write_all(format!("contents {}", i).as_bytes()) .unwrap(); } let (reloaded_repo, commit) = wc.commit(&settings, repo); repo = reloaded_repo; let new_tree_id = commit.tree().id().clone(); assert_ne!(new_tree_id, previous_tree_id); previous_tree_id = new_tree_id; } } #[test_case(false ; "local store")] #[test_case(true ; "git store")] fn test_gitignores(use_git: bool) { // Tests that .gitignore files are respected. let _home_dir = testutils::new_user_home(); let settings = testutils::user_settings(); let (_temp_dir, repo) = testutils::init_repo(&settings, use_git); let gitignore_path = FileRepoPath::from(".gitignore"); let added_path = FileRepoPath::from("added"); let modified_path = FileRepoPath::from("modified"); let removed_path = FileRepoPath::from("removed"); let ignored_path = FileRepoPath::from("ignored"); let subdir_modified_path = FileRepoPath::from("dir/modified"); let subdir_ignored_path = FileRepoPath::from("dir/ignored"); testutils::write_working_copy_file(&repo, &gitignore_path, "ignored\n"); testutils::write_working_copy_file(&repo, &modified_path, "1"); testutils::write_working_copy_file(&repo, &removed_path, "1"); std::fs::create_dir(repo.working_copy_path().join("dir")).unwrap(); testutils::write_working_copy_file(&repo, &subdir_modified_path, "1"); let wc = repo.working_copy().clone(); let (repo, commit1) = wc.lock().unwrap().commit(&settings, repo); let files1: Vec<_> = commit1 .tree() .entries() .map(|(name, _value)| name) .collect(); assert_eq!( files1, vec![ gitignore_path.to_repo_path(), subdir_modified_path.to_repo_path(), modified_path.to_repo_path(), removed_path.to_repo_path() ] ); testutils::write_working_copy_file(&repo, &gitignore_path, "ignored\nmodified\nremoved\n"); testutils::write_working_copy_file(&repo, &added_path, "2"); testutils::write_working_copy_file(&repo, &modified_path, "2"); std::fs::remove_file( repo.working_copy_path() .join(removed_path.to_internal_string()), ) .unwrap(); testutils::write_working_copy_file(&repo, &ignored_path, "2"); testutils::write_working_copy_file(&repo, &subdir_modified_path, "2"); testutils::write_working_copy_file(&repo, &subdir_ignored_path, "2"); let wc = repo.working_copy().clone(); let (_repo, commit2) = wc.lock().unwrap().commit(&settings, repo); let files2: Vec<_> = commit2 .tree() .entries() .map(|(name, _value)| name) .collect(); assert_eq!( files2, vec![ gitignore_path.to_repo_path(), added_path.to_repo_path(), subdir_modified_path.to_repo_path(), modified_path.to_repo_path() ] ); } #[test_case(false ; "local store")] #[test_case(true ; "git store")] fn test_gitignores_checkout_overwrites_ignored(use_git: bool) { // Tests that a .gitignore'd file gets overwritten if check out a commit where // the file is tracked. let _home_dir = testutils::new_user_home(); let settings = testutils::user_settings(); let (_temp_dir, repo) = testutils::init_repo(&settings, use_git); // Write an ignored file called "modified" to disk let gitignore_path = FileRepoPath::from(".gitignore"); testutils::write_working_copy_file(&repo, &gitignore_path, "modified\n"); let modified_path = FileRepoPath::from("modified"); testutils::write_working_copy_file(&repo, &modified_path, "garbage"); // Create a commit that adds the same file but with different contents let mut tx = repo.start_transaction("test"); let mut tree_builder = repo .store() .tree_builder(repo.store().empty_tree_id().clone()); testutils::write_normal_file(&mut tree_builder, &modified_path, "contents"); let tree_id = tree_builder.write_tree(); let commit = CommitBuilder::for_new_commit(&settings, repo.store(), tree_id) .set_open(true) .set_description("add file".to_string()) .write_to_repo(tx.mut_repo()); let repo = tx.commit(); // Now check out the commit that adds the file "modified" with contents // "contents". The exiting contents ("garbage") should be replaced in the // working copy. repo.working_copy_locked().check_out(commit).unwrap(); // Check that the new contents are in the working copy let path = repo.working_copy_path().join("modified"); assert!(path.is_file()); let mut file = File::open(path).unwrap(); let mut buf = Vec::new(); file.read_to_end(&mut buf).unwrap(); assert_eq!(buf, b"contents"); // Check that the file is in the commit created by committing the working copy let (_repo, new_commit) = repo.working_copy_locked().commit(&settings, repo.clone()); assert!(new_commit.tree().entry("modified").is_some()); } #[test_case(false ; "local store")] #[test_case(true ; "git store")] fn test_gitignores_ignored_directory_already_tracked(use_git: bool) { // Tests that a .gitignore'd directory that already has a tracked file in it // does not get removed when committing the working directory. let _home_dir = testutils::new_user_home(); let settings = testutils::user_settings(); let (_temp_dir, repo) = testutils::init_repo(&settings, use_git); // Add a .gitignore file saying to ignore the directory "ignored/" let gitignore_path = FileRepoPath::from(".gitignore"); testutils::write_working_copy_file(&repo, &gitignore_path, "/ignored/\n"); let file_path = FileRepoPath::from("ignored/file"); // Create a commit that adds a file in the ignored directory let mut tx = repo.start_transaction("test"); let mut tree_builder = repo .store() .tree_builder(repo.store().empty_tree_id().clone()); testutils::write_normal_file(&mut tree_builder, &file_path, "contents"); let tree_id = tree_builder.write_tree(); let commit = CommitBuilder::for_new_commit(&settings, repo.store(), tree_id) .set_open(true) .set_description("add ignored file".to_string()) .write_to_repo(tx.mut_repo()); let repo = tx.commit(); // Check out the commit with the file in ignored/ repo.working_copy_locked().check_out(commit).unwrap(); // Check that the file is still in the commit created by committing the working // copy (that it didn't get removed because the directory is ignored) let (_repo, new_commit) = repo.working_copy_locked().commit(&settings, repo.clone()); assert!(new_commit .tree() .path_value(&file_path.to_repo_path()) .is_some()); }