diff --git a/Cargo.lock b/Cargo.lock index 94caeaa802f..86d1618ff23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,6 +1717,16 @@ dependencies = [ [[package]] name = "git-worktree" version = "0.0.0" +dependencies = [ + "git-hash 0.9.2", + "git-index", + "git-object 0.17.1", + "git-odb 0.26.0", + "git-testtools", + "quick-error", + "tempfile", + "walkdir", +] [[package]] name = "git2" diff --git a/git-index/src/access.rs b/git-index/src/access.rs index 2202cdd659e..ca137e6c234 100644 --- a/git-index/src/access.rs +++ b/git-index/src/access.rs @@ -8,6 +8,9 @@ impl State { pub fn entries(&self) -> &[Entry] { &self.entries } + pub fn entries_mut(&mut self) -> &mut [Entry] { + &mut self.entries + } pub fn tree(&self) -> Option<&extension::Tree> { self.tree.as_ref() } diff --git a/git-worktree/Cargo.toml b/git-worktree/Cargo.toml index 6aa73e98b5e..abc5ce18c1b 100644 --- a/git-worktree/Cargo.toml +++ b/git-worktree/Cargo.toml @@ -13,3 +13,13 @@ doctest = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +git-index = { version = "^0.1.0", path = "../git-index" } +quick-error = "2.0.1" +git-hash = { version = "^0.9.0", path = "../git-hash" } +git-object = { version = "^0.17.0", path = "../git-object" } + +[dev-dependencies] +git-odb = { path = "../git-odb" } +walkdir = "2.3.2" +git-testtools = { path = "../tests/tools" } +tempfile = "3.2.0" diff --git a/git-worktree/src/lib.rs b/git-worktree/src/lib.rs index d7a83e4f525..b68f45a3d80 100644 --- a/git-worktree/src/lib.rs +++ b/git-worktree/src/lib.rs @@ -1 +1,129 @@ #![forbid(unsafe_code, rust_2018_idioms)] +//! Git Worktree + +use git_hash::oid; +use git_object::bstr::ByteSlice; +use quick_error::quick_error; +use std::convert::TryFrom; +use std::fs::{create_dir_all, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +quick_error! { + #[derive(Debug)] + pub enum Error { + Utf8(err: git_object::bstr::Utf8Error) { + from() + display("Could not convert path to UTF8: {}", err) + } + Time(err: std::time::SystemTimeError) { + from() + display("Could not read file time in proper format: {}", err) + } + U32Conversion(err: std::num::TryFromIntError) { + from() + display("Could not convert seconds to u32: {}", err) + } + Io(err: std::io::Error) { + from() + display("IO error while writing blob or reading file metadata or changing filetype: {}", err) + } + NotFound(oid: git_hash::ObjectId, path: PathBuf) { + display("unable find object of {} ({})", path.display(), oid.to_hex()) + } + } +} + +/// Copy index to `path` +pub fn copy_index(state: &mut git_index::State, path: P, mut find: Find, opts: Options) -> Result<(), Error> +where + P: AsRef, + Find: for<'a> FnMut(&oid, &'a mut Vec) -> Option>, +{ + let path = path.as_ref(); + let mut buf = Vec::new(); + let mut entry_time = Vec::new(); // Entries whose timestamps have to be updated + for (i, entry) in state.entries().iter().enumerate() { + if entry.flags.contains(git_index::entry::Flags::SKIP_WORKTREE) { + continue; + } + let entry_path = entry.path(state).to_path()?; + let dest = path.join(entry_path); + create_dir_all(dest.parent().expect("entry paths are never empty"))?; + match entry.mode { + git_index::entry::Mode::FILE | git_index::entry::Mode::FILE_EXECUTABLE => { + let obj = find(&entry.id, &mut buf).ok_or_else(|| Error::NotFound(entry.id, path.to_path_buf()))?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if entry.mode == git_index::entry::Mode::FILE_EXECUTABLE { + options.mode(0o777); + } + let mut file = options.open(&dest)?; + file.write_all(obj.data)?; + let met = file.metadata()?; + let ctime = met + .created() + .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH)); + let mtime = met + .modified() + .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH)); + entry_time.push((ctime?, mtime?, i)); + } + git_index::entry::Mode::SYMLINK => { + let obj = find(&entry.id, &mut buf).ok_or_else(|| Error::NotFound(entry.id, path.to_path_buf()))?; + let linked_to = obj.data.to_path()?; + if opts.symlinks { + #[cfg(unix)] + std::os::unix::fs::symlink(linked_to, &dest)?; + #[cfg(windows)] + if dest.exists() { + if dest.is_file() { + std::os::windows::fs::symlink_file(linked_to, &dest)?; + } else { + std::os::windows::fs::symlink_dir(linked_to, &dest)?; + } + } + } else { + std::fs::write(&dest, obj.data)?; + } + let met = std::fs::symlink_metadata(&dest)?; + let ctime = met + .created() + .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH)); + let mtime = met + .modified() + .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH)); + entry_time.push((ctime?, mtime?, i)); + } + git_index::entry::Mode::DIR => todo!(), + git_index::entry::Mode::COMMIT => todo!(), + _ => unreachable!(), + } + } + let entries = state.entries_mut(); + for (ctime, mtime, i) in entry_time { + let stat = &mut entries[i].stat; + stat.mtime.secs = u32::try_from(mtime.as_secs())?; + stat.mtime.nsecs = mtime.subsec_nanos(); + stat.ctime.secs = u32::try_from(ctime.as_secs())?; + stat.ctime.nsecs = ctime.subsec_nanos(); + } + Ok(()) +} + +/// Options for [copy_index](crate::copy_index) +pub struct Options { + /// Enable/disable symlinks + pub symlinks: bool, +} + +impl Default for Options { + fn default() -> Self { + Options { symlinks: true } + } +} diff --git a/git-worktree/tests/copy_index/mod.rs b/git-worktree/tests/copy_index/mod.rs new file mode 100644 index 00000000000..ce8a4efd236 --- /dev/null +++ b/git-worktree/tests/copy_index/mod.rs @@ -0,0 +1,103 @@ +use crate::{dir_structure, fixture_path}; +use git_object::bstr::ByteSlice; +use git_odb::FindExt; +use git_worktree::{copy_index, Options}; +use std::fs; + +#[cfg(unix)] +use std::os::unix::prelude::MetadataExt; + +#[test] +fn test_copy_index() -> crate::Result<()> { + let path = fixture_path("make_repo"); + let path_git = path.join(".git"); + let mut file = git_index::File::at(path_git.join("index"), git_index::decode::Options::default())?; + let output_dir = tempfile::tempdir()?; + let output = output_dir.path(); + let odb_handle = git_odb::at(path_git.join("objects"))?; + + copy_index( + &mut file, + &output, + move |oid, buf| odb_handle.find_blob(oid, buf).ok(), + Options::default(), + )?; + + let repo_files = dir_structure(&path); + let copy_files = dir_structure(output); + + assert_eq!( + repo_files + .iter() + .flat_map(|p| p.strip_prefix(&path)) + .collect::>(), + copy_files + .iter() + .flat_map(|p| p.strip_prefix(output)) + .collect::>() + ); + + for (file1, file2) in repo_files.iter().zip(copy_files.iter()) { + assert_eq!(fs::read(file1)?, fs::read(file2)?); + #[cfg(unix)] + assert_eq!( + fs::symlink_metadata(file1)?.mode() & 0o700, + fs::symlink_metadata(file2)?.mode() & 0o700, + "Testing if the permissions (normal/executable) of {} and {} are the same", + file1.display(), + file2.display(), + ); + } + + Ok(()) +} + +#[test] +fn test_copy_index_without_symlinks() -> crate::Result<()> { + let path = fixture_path("make_repo"); + let path_git = path.join(".git"); + let mut file = git_index::File::at(path_git.join("index"), git_index::decode::Options::default())?; + let output_dir = tempfile::tempdir()?; + let output = output_dir.path(); + let odb_handle = git_odb::at(path_git.join("objects"))?; + + copy_index( + &mut file, + &output, + move |oid, buf| odb_handle.find_blob(oid, buf).ok(), + Options { symlinks: false }, + )?; + + let repo_files = dir_structure(&path); + let copy_files = dir_structure(output); + + assert_eq!( + repo_files + .iter() + .flat_map(|p| p.strip_prefix(&path)) + .collect::>(), + copy_files + .iter() + .flat_map(|p| p.strip_prefix(output)) + .collect::>() + ); + + for (file1, file2) in repo_files.iter().zip(copy_files.iter()) { + if file1.is_symlink() { + assert!(!file2.is_symlink()); + assert_eq!(fs::read(file2)?.to_path()?, fs::read_link(file1)?); + } else { + assert_eq!(fs::read(file1)?, fs::read(file2)?); + #[cfg(unix)] + assert_eq!( + fs::symlink_metadata(file1)?.mode() & 0o700, + fs::symlink_metadata(file2)?.mode() & 0o700, + "Testing if the permissions (normal/executable) of {} and {} are the same", + file1.display(), + file2.display(), + ); + } + } + + Ok(()) +} diff --git a/git-worktree/tests/fixtures/make_repo.sh b/git-worktree/tests/fixtures/make_repo.sh new file mode 100755 index 00000000000..cc2c6ccf42d --- /dev/null +++ b/git-worktree/tests/fixtures/make_repo.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -eu -o pipefail + +git init -q +git config commit.gpgsign false + +touch a +echo "Test Vals" > a +touch b +touch c +touch executable.sh +chmod +x executable.sh + +mkdir d +touch d/a +echo "Subdir" > d/a +ln -sf d/a sa + +git add -A +git commit -m "Commit" diff --git a/git-worktree/tests/mod.rs b/git-worktree/tests/mod.rs new file mode 100644 index 00000000000..236c1fbebd0 --- /dev/null +++ b/git-worktree/tests/mod.rs @@ -0,0 +1,25 @@ +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +mod copy_index; + +type Result = std::result::Result>; + +pub fn dir_structure>(path: P) -> Vec { + let path = path.as_ref(); + let mut ps: Vec<_> = WalkDir::new(path) + .into_iter() + .filter_entry(|e| e.path() == path || !e.file_name().to_str().map(|s| s.starts_with('.')).unwrap_or(false)) + .flatten() + .filter(|e| e.path().is_file()) + .map(|p| p.path().to_path_buf()) + .collect(); + ps.sort(); + ps +} + +pub fn fixture_path(name: &str) -> PathBuf { + let dir = + git_testtools::scripted_fixture_repo_read_only(Path::new(name).with_extension("sh")).expect("script works"); + dir +}