git-worktree 0.12.1

A WIP crate of the gitoxide project dedicated implementing everything around working trees and git excludes
Documentation
use std::{convert::TryInto, fs::OpenOptions, io::Write, path::Path, time::Duration};

use bstr::BStr;
use git_hash::oid;
use git_index::Entry;
use io_close::Close;

use crate::{fs, index, os};

pub struct Context<'a, Find> {
    pub find: &'a mut Find,
    pub path_cache: &'a mut fs::Cache,
    pub buf: &'a mut Vec<u8>,
}

#[cfg_attr(not(unix), allow(unused_variables))]
pub fn checkout<Find, E>(
    entry: &mut Entry,
    entry_path: &BStr,
    Context { find, path_cache, buf }: Context<'_, Find>,
    index::checkout::Options {
        fs: fs::Capabilities {
            symlink,
            executable_bit,
            ..
        },
        destination_is_initially_empty,
        overwrite_existing,
        ..
    }: index::checkout::Options,
) -> Result<usize, index::checkout::Error<E>>
where
    Find: for<'a> FnMut(&oid, &'a mut Vec<u8>) -> Result<git_object::BlobRef<'a>, E>,
    E: std::error::Error + Send + Sync + 'static,
{
    let dest_relative = git_path::try_from_bstr(entry_path).map_err(|_| index::checkout::Error::IllformedUtf8 {
        path: entry_path.to_owned(),
    })?;
    let is_dir = Some(entry.mode == git_index::entry::Mode::COMMIT || entry.mode == git_index::entry::Mode::DIR);
    let dest = path_cache.at_path(dest_relative, is_dir, &mut *find)?.path();

    let object_size = match entry.mode {
        git_index::entry::Mode::FILE | git_index::entry::Mode::FILE_EXECUTABLE => {
            let obj = find(&entry.id, buf).map_err(|err| index::checkout::Error::Find {
                err,
                oid: entry.id,
                path: dest.to_path_buf(),
            })?;

            #[cfg_attr(not(unix), allow(unused_mut))]
            let mut options = open_options(dest, destination_is_initially_empty, overwrite_existing);
            let needs_executable_bit = executable_bit && entry.mode == git_index::entry::Mode::FILE_EXECUTABLE;
            #[cfg(unix)]
            if needs_executable_bit && destination_is_initially_empty {
                use std::os::unix::fs::OpenOptionsExt;
                // Note that these only work if the file was newly created, but won't if it's already
                // existing, possibly without the executable bit set. Thus we do this only if the file is new.
                options.mode(0o777);
            }

            let mut file = try_write_or_unlink(dest, overwrite_existing, |p| options.open(p))?;
            file.write_all(obj.data)?;

            // For possibly existing, overwritten files, we must change the file mode explicitly.
            #[cfg(unix)]
            if needs_executable_bit && !destination_is_initially_empty {
                use std::os::unix::fs::PermissionsExt;
                let mut perm = std::fs::symlink_metadata(dest)?.permissions();
                perm.set_mode(0o777);
                std::fs::set_permissions(dest, perm)?;
            }
            // NOTE: we don't call `file.sync_all()` here knowing that some filesystems don't handle this well.
            //       revisit this once there is a bug to fix.
            update_fstat(entry, file.metadata()?)?;
            file.close()?;
            obj.data.len()
        }
        git_index::entry::Mode::SYMLINK => {
            let obj = find(&entry.id, buf).map_err(|err| index::checkout::Error::Find {
                err,
                oid: entry.id,
                path: dest.to_path_buf(),
            })?;
            let symlink_destination = git_path::try_from_byte_slice(obj.data)
                .map_err(|_| index::checkout::Error::IllformedUtf8 { path: obj.data.into() })?;

            if symlink {
                try_write_or_unlink(dest, overwrite_existing, |p| os::create_symlink(symlink_destination, p))?;
            } else {
                let mut file = try_write_or_unlink(dest, overwrite_existing, |p| {
                    open_options(p, destination_is_initially_empty, overwrite_existing).open(dest)
                })?;
                file.write_all(obj.data)?;
                file.close()?;
            }

            update_fstat(entry, std::fs::symlink_metadata(dest)?)?;
            obj.data.len()
        }
        git_index::entry::Mode::DIR => todo!(),
        git_index::entry::Mode::COMMIT => todo!(),
        _ => unreachable!(),
    };
    Ok(object_size)
}

/// Note that this works only because we assume to not race ourselves when symlinks are involved, and we do this by
/// delaying symlink creation to the end and will always do that sequentially.
/// It's still possible to fall for a race if other actors create symlinks in our path, but that's nothing to defend against.
fn try_write_or_unlink<T>(
    path: &Path,
    overwrite_existing: bool,
    op: impl Fn(&Path) -> std::io::Result<T>,
) -> std::io::Result<T> {
    if overwrite_existing {
        match op(path) {
            Ok(res) => Ok(res),
            Err(err) if os::indicates_collision(&err) => {
                try_unlink_path_recursively(path, &std::fs::symlink_metadata(path)?)?;
                op(path)
            }
            Err(err) => Err(err),
        }
    } else {
        op(path)
    }
}

fn try_unlink_path_recursively(path: &Path, path_meta: &std::fs::Metadata) -> std::io::Result<()> {
    if path_meta.is_dir() {
        std::fs::remove_dir_all(path)
    } else if path_meta.file_type().is_symlink() {
        os::remove_symlink(path)
    } else {
        std::fs::remove_file(path)
    }
}

#[cfg(not(debug_assertions))]
fn debug_assert_dest_is_no_symlink(_path: &Path) {}

/// This is a debug assertion as we expect the machinery calling this to prevent this possibility in the first place
#[cfg(debug_assertions)]
fn debug_assert_dest_is_no_symlink(path: &Path) {
    if let Ok(meta) = path.metadata() {
        debug_assert!(
            !meta.file_type().is_symlink(),
            "BUG: should not ever allow to overwrite/write-into the target of a symbolic link: {}",
            path.display()
        );
    }
}

fn open_options(path: &Path, destination_is_initially_empty: bool, overwrite_existing: bool) -> OpenOptions {
    if overwrite_existing || !destination_is_initially_empty {
        debug_assert_dest_is_no_symlink(path);
    }
    let mut options = git_features::fs::open_options_no_follow();
    options
        .create_new(destination_is_initially_empty && !overwrite_existing)
        .create(!destination_is_initially_empty || overwrite_existing)
        .write(true);
    options
}

fn update_fstat<E>(entry: &mut Entry, meta: std::fs::Metadata) -> Result<(), index::checkout::Error<E>>
where
    E: std::error::Error + Send + Sync + 'static,
{
    let ctime = meta
        .created()
        .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?;
    let mtime = meta
        .modified()
        .map_or(Ok(Duration::default()), |x| x.duration_since(std::time::UNIX_EPOCH))?;

    let stat = &mut entry.stat;
    stat.mtime.secs = mtime
        .as_secs()
        .try_into()
        .expect("by 2038 we found a solution for this");
    stat.mtime.nsecs = mtime.subsec_nanos();
    stat.ctime.secs = ctime
        .as_secs()
        .try_into()
        .expect("by 2038 we found a solution for this");
    stat.ctime.nsecs = ctime.subsec_nanos();
    Ok(())
}