use std::{
ffi::OsString,
fs, io,
io::Write,
path::{Path, PathBuf},
};
#[derive(Clone, Copy, Debug)]
pub struct Context<'a> {
pub common_dir: &'a Path,
pub git_dir: Option<&'a Path>,
}
impl<'a> Context<'a> {
pub fn new(common_dir: &'a Path) -> Self {
Self {
common_dir,
git_dir: None,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Options {
pub relative_paths: bool,
}
#[derive(Debug)]
pub struct PreparedWorktree {
common_dir: PathBuf,
git_dir: PathBuf,
work_dir: PathBuf,
work_dir_cleanup: Option<WorkDirCleanup>,
rollback: bool,
}
#[derive(Debug)]
enum WorkDirCleanup {
ClearContents,
RemoveDirectory,
}
impl PreparedWorktree {
pub fn common_dir(&self) -> &Path {
&self.common_dir
}
pub fn git_dir(&self) -> &Path {
&self.git_dir
}
pub fn work_dir(&self) -> &Path {
&self.work_dir
}
}
impl PreparedWorktree {
pub fn persist(mut self) -> io::Result<()> {
fs::remove_file(self.git_dir.join("locked"))?;
self.rollback = false;
Ok(())
}
pub fn rollback(mut self) -> io::Result<()> {
self.rollback = false;
self.rollback_inner()
}
fn rollback_inner(&self) -> io::Result<()> {
let work_dir = match self.work_dir_cleanup {
None => Ok(()),
Some(WorkDirCleanup::ClearContents) => remove_contents_non_recursively(&self.work_dir),
Some(WorkDirCleanup::RemoveDirectory) => fs::remove_dir_all(&self.work_dir),
};
let git_dir = fs::remove_dir_all(&self.git_dir);
work_dir.and(git_dir)
}
}
impl Drop for PreparedWorktree {
fn drop(&mut self) {
if self.rollback {
let _ = self.rollback_inner();
}
}
}
pub fn prepare(
destination_dir: impl AsRef<Path>,
context: Context<'_>,
Options { relative_paths }: Options,
) -> io::Result<PreparedWorktree> {
let common_dir = context.common_dir;
ensure_directory(common_dir, "common Git directory")?;
let work_dir = destination_dir.as_ref();
let empty_destination_exists = validate_destination(work_dir)?;
let common_dir = gix_path::realpath(common_dir).map_err(io::Error::other)?;
let work_dir = gix_path::realpath(work_dir).map_err(io::Error::other)?;
let worktrees_dir = common_dir.join("worktrees");
fs::create_dir_all(&worktrees_dir)?;
ensure_directory(&worktrees_dir, "worktrees directory")?;
let basename = work_dir.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("worktree destination \"{}\" has no basename", work_dir.display()),
)
})?;
let basename = gix_path::os_str_into_bstr(basename)
.map(gix_validate::reference::name_partial_or_sanitize)
.and_then(gix_path::from_bstring)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let git_dir = reserve_git_dir(&worktrees_dir, basename.as_os_str())?;
let mut prepared = PreparedWorktree {
common_dir,
git_dir,
work_dir,
work_dir_cleanup: None,
rollback: true,
};
if !empty_destination_exists {
if let Some(parent) = prepared.work_dir.parent() {
fs::create_dir_all(parent)?;
}
match fs::create_dir(&prepared.work_dir) {
Ok(()) => prepared.work_dir_cleanup = Some(WorkDirCleanup::RemoveDirectory),
Err(err) => return Err(err),
}
}
fs::write(prepared.git_dir.join("locked"), b"initializing\n")?;
write_path(
prepared.git_dir.join("gitdir"),
b"",
&link_path(&prepared.work_dir.join(".git"), &prepared.git_dir, relative_paths),
)?;
fs::write(prepared.git_dir.join("commondir"), b"../..\n")?;
let dot_git = prepared.work_dir.join(".git");
let mut dot_git = fs::OpenOptions::new().write(true).create_new(true).open(dot_git)?;
if empty_destination_exists {
prepared.work_dir_cleanup = Some(WorkDirCleanup::ClearContents);
}
write_path_to(
&mut dot_git,
b"gitdir: ",
&link_path(&prepared.git_dir, &prepared.work_dir, relative_paths),
)?;
if let Some(git_dir) = context.git_dir
&& let Some(patterns) = crate::sparse::read_patterns(git_dir)?
{
let info_dir = prepared.git_dir.join("info");
fs::create_dir_all(&info_dir)?;
fs::write(info_dir.join("sparse-checkout"), patterns)?;
}
Ok(prepared)
}
fn link_path(target: &Path, base: &Path, relative: bool) -> PathBuf {
if relative
&& let Some(root) = base.ancestors().last()
&& let Ok(target) = target.strip_prefix(root)
&& let Ok(base) = base.strip_prefix(root)
{
gix_path::relativize_with_prefix(target, base).into_owned()
} else {
target.to_owned()
}
}
fn validate_destination(path: &Path) -> io::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("worktree destination \"{}\" is a symbolic link", path.display()),
)),
Ok(metadata) if !metadata.is_dir() => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("worktree destination \"{}\" is not a directory", path.display()),
)),
Ok(_) => match fs::read_dir(path)?.next().transpose()? {
Some(_) => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("worktree destination \"{}\" is not empty", path.display()),
)),
None => Ok(true),
},
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
fn ensure_directory(path: &Path, name: &str) -> io::Result<()> {
if !fs::metadata(path)?.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} \"{}\" is not a directory", path.display()),
));
}
Ok(())
}
fn reserve_git_dir(parent: &Path, basename: &std::ffi::OsStr) -> io::Result<PathBuf> {
let mut suffix = 0_u64;
loop {
let mut name = OsString::from(basename);
if suffix != 0 {
name.push(suffix.to_string());
}
let candidate = parent.join(name);
match fs::create_dir(&candidate) {
Ok(()) => return Ok(candidate),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
suffix = suffix
.checked_add(1)
.ok_or_else(|| io::Error::other("worktree ID suffix overflow"))?;
}
Err(err) => return Err(err),
}
}
}
fn write_path(path: PathBuf, prefix: &[u8], value: &Path) -> io::Result<()> {
let mut file = fs::File::create(path)?;
write_path_to(&mut file, prefix, value)
}
fn write_path_to(mut out: impl Write, prefix: &[u8], value: &Path) -> io::Result<()> {
let value = gix_path::into_bstr(value).map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let value = gix_path::to_unix_separators_on_windows(value);
out.write_all(prefix)?;
out.write_all(&value)?;
out.write_all(b"\n")
}
fn remove_contents_non_recursively(directory: &Path) -> io::Result<()> {
for entry in fs::read_dir(directory)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
gix_fs::symlink::remove(&entry.path())?;
} else if file_type.is_dir() {
fs::remove_dir_all(entry.path())?;
} else {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
#[cfg(all(test, windows))]
mod tests {
use super::link_path;
use std::path::Path;
#[test]
fn relative_links_across_filesystem_roots_remain_absolute() {
for (target, base) in [
("D:/worktree/.git", "C:/repo.git/worktrees/id"),
("//server/other/worktree/.git", "//server/share/repo.git/worktrees/id"),
] {
assert_eq!(
link_path(Path::new(target), Path::new(base), true),
Path::new(target),
"different drives or UNC shares cannot be linked with relative paths"
);
}
assert_eq!(
link_path(
Path::new("C:/worktree/.git"),
Path::new("C:/repo.git/worktrees/id"),
true
),
Path::new("../../../worktree/.git"),
"paths on the same drive can be relative"
);
}
}