working_copy: add support for .gitignore files

The project's source of truth is now in Git and I really miss support
for anonymous heads and evolution (compared to when the code was in
Mercurial). I'm therefore more motivated to make the tool useful for
day-to-day work on small repos, so I can use it myself. Until now, I
had been more focused on improving performance when it was used as a
read-only client for medium-to-large repos.

One important feature for my day-to-day work is support for
ignores. This commit adds simple and effective, but somewhat hacky
support for that. libgit2 requires a repo to check if a file should be
ignored (presumably so it can respect `.git/info/excludes`). To work
around that, we create a temporary git repo in `/tmp/` whenever the
working copy is committed. We set that temporary git repo's working
copy to be shared with our own working copy. Due to
https://github.com/libgit2/libgit2sharp/issues/1716 (which seems to
apply to the non-.NET version as well), this workaround unfortunately
leaves a .git file (pointing to the deleted temporary git repo) around
in every Jujube repo. That's always ignored by libgit2, so it's not
much of a problem.
This commit is contained in:
Martin von Zweigbergk 2020-12-18 22:50:01 -08:00
parent 9ad225b3b5
commit 3b326a942c
2 changed files with 94 additions and 3 deletions

View File

@ -41,6 +41,7 @@ use crate::settings::UserSettings;
use crate::store::{CommitId, FileId, MillisSinceEpoch, StoreError, SymlinkId, TreeId, TreeValue};
use crate::store_wrapper::StoreWrapper;
use crate::trees::TreeValueDiff;
use git2::{Repository, RepositoryInitOptions};
use std::sync::Arc;
#[derive(Debug, PartialEq, Eq, Clone)]
@ -169,9 +170,11 @@ impl TreeState {
state_path: PathBuf,
) -> TreeState {
let tree_id = store.empty_tree_id().clone();
// Canonicalize the working copy path because "repo/." makes libgit2 think that
// everything should be ignored
TreeState {
store,
working_copy_path,
working_copy_path: working_copy_path.canonicalize().unwrap(),
state_path,
tree_id,
file_states: BTreeMap::new(),
@ -272,6 +275,14 @@ impl TreeState {
// a new tree from it and return it, and also update the dirstate on disk.
// TODO: respect ignores
pub fn write_tree(&mut self) -> &TreeId {
// We create a temporary git repo with the working copy shared with ours only
// so we can use libgit2's .gitignore check.
// TODO: Do this more cleanly, perhaps by reading .gitignore files ourselves.
let git_repo_dir = tempfile::tempdir().unwrap();
let mut git_repo_options = RepositoryInitOptions::new();
git_repo_options.workdir_path(&self.working_copy_path);
let git_repo = Repository::init_opts(git_repo_dir.path(), &git_repo_options).unwrap();
let mut work = vec![(DirRepoPath::root(), self.working_copy_path.clone())];
let mut tree_builder = self.store.tree_builder(self.tree_id.clone());
let mut deleted_files: HashSet<&FileRepoPath> = self.file_states.keys().collect();
@ -292,16 +303,22 @@ impl TreeState {
work.push((subdir, disk_subdir));
} else {
let file = dir.join(&FileRepoPathComponent::from(name));
let disk_file = disk_dir.join(file_name);
deleted_files.remove(&file);
let new_file_state = self.file_state(&entry.path()).unwrap();
let clean = match self.file_states.get(&file) {
None => false, // untracked
None => {
// untracked
if git_repo.status_should_ignore(&disk_file).unwrap() {
continue;
}
false
}
Some(current_entry) => {
current_entry == &new_file_state && current_entry.mtime < self.read_time
}
};
if !clean {
let disk_file = disk_dir.join(file_name);
let file_value = match new_file_state.file_type {
FileType::Normal | FileType::Executable => {
let id = self.write_file_to_store(&file, &disk_file);

View File

@ -265,3 +265,77 @@ fn test_commit_racy_timestamps(use_git: bool) {
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 settings = testutils::user_settings();
let (_temp_dir, mut 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");
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 commit1 = wc
.lock()
.unwrap()
.commit(&settings, Arc::get_mut(&mut repo).unwrap());
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, &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 commit2 = wc
.lock()
.unwrap()
.commit(&settings, Arc::get_mut(&mut repo).unwrap());
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()
]
);
}