//! Prepare the administrative files for a linked worktree. use std::{ ffi::OsString, fs, io, io::Write, path::{Path, PathBuf}, }; /// Options for linking a new worktree to its shared Git directory. #[derive(Clone, Copy, Debug, Default)] pub struct Options { /// The parsed `core.sharedRepository` policy for repository metadata. /// /// This applies to the common `worktrees` directory and, matching Git's preparation of `/.git`, /// to the destination and any parent directories created by [`prepare()`]. Existing directories and the /// new private Git directory retain their normal filesystem permissions. /// /// Unlike Git, the linking files `.git`, `gitdir`, `commondir`, and `locked` also receive these permissions. /// This option does not configure permissions for subsequently checked-out files or subdirectories. /// See [`gix_fs::adjust_shared_repository_permissions()`] for the signed mode encoding; `0` is the default. pub shared_repository_permissions: i32, /// Write relative paths into the `.git` and `gitdir` files, falling back to absolute paths across filesystem roots. /// /// This does not affect the paths passed to [`prepare()`], which may be absolute or relative regardless of /// this option. Relative links are computed automatically from those paths. /// /// This allows the repository and its worktrees to be moved together, or mounted at different absolute paths /// on a host and in a container, without repairing their links. Their relative directory layout must remain /// unchanged; moving just one worktree can still break the links. /// /// The caller must enable `extensions.relativeWorktrees` and repository format version 1 in the shared config. /// This compatibility marker prevents older Git versions from misinterpreting relative links. pub relative_paths: bool, } /// A linked worktree whose administrative files are prepared, but whose checkout is not complete yet. /// /// Unless [`persist()`][PreparedWorktree::persist()] is called, dropping this value performs /// [`rollback()`][PreparedWorktree::rollback()], ignoring cleanup errors. Call it explicitly to handle these errors. #[derive(Debug)] pub struct PreparedWorktree { /// Absolute path to the shared Git directory, such as `/repo/.git`, with symbolic links resolved. common_dir: PathBuf, /// Absolute path to this worktree's private administrative directory at `/worktrees/`. git_dir: PathBuf, /// Absolute path to the checkout destination, with symbolic links in its parent components resolved. work_dir: PathBuf, work_dir_cleanup: Option, rollback: bool, } #[derive(Debug)] enum WorkDirCleanup { ClearContents, RemoveDirectory, } impl PreparedWorktree { /// Return the shared Git directory. pub fn common_dir(&self) -> &Path { &self.common_dir } /// Return the newly reserved private Git directory below `worktrees/`. pub fn git_dir(&self) -> &Path { &self.git_dir } /// Return the worktree directory. pub fn work_dir(&self) -> &Path { &self.work_dir } } /// Lifecycle impl PreparedWorktree { /// Finish adding the worktree by removing the initialization lock and disabling rollback. pub fn persist(mut self) -> io::Result<()> { fs::remove_file(self.git_dir.join("locked"))?; self.rollback = false; Ok(()) } /// Remove the private Git directory and undo adding the worktree. /// /// If [`prepare()`] created the destination directory, remove it and all its contents. If the destination /// already existed, remove its contents but leave the directory itself in place. This includes files added /// during checkout. Parent directories, including any created by [`prepare()`], remain in place. /// /// Both worktree cleanup and private Git directory removal are attempted, returning the first error, if any. /// This consumes the value even on error; dropping it will not retry cleanup. 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(); } } } /// Reserve and initialize the administrative files for a linked worktree at `destination_dir`. /// /// `common_dir` must be an existing shared Git directory, such as `./repo/.git` for a non-bare repository /// or `./repo.git` for a bare repository. `destination` must be absent or an empty directory. Its final /// path component must not be a symbolic link, but parent components may be symbolic links and are resolved /// before creating the administrative files. The private Git directory is named after the sanitized destination /// basename, with a numeric suffix added when needed. /// /// Paths in the returned [`PreparedWorktree`] are absolute regardless of the link format selected by `options`. pub fn prepare( common_dir: impl AsRef, destination_dir: impl AsRef, Options { relative_paths, shared_repository_permissions, }: Options, ) -> io::Result { let common_dir = common_dir.as_ref(); 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"); gix_fs::dir::create::all(&worktrees_dir, Default::default(), shared_repository_permissions)?; 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::try_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() { gix_fs::dir::create::all(parent, Default::default(), shared_repository_permissions)?; } match fs::create_dir(&prepared.work_dir) { Ok(()) => prepared.work_dir_cleanup = Some(WorkDirCleanup::RemoveDirectory), Err(err) => return Err(err), } gix_fs::set_shared_repository_permissions(&prepared.work_dir, shared_repository_permissions)?; } 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), )?; for path in [ prepared.work_dir.join(".git"), prepared.git_dir.join("gitdir"), prepared.git_dir.join("commondir"), prepared.git_dir.join("locked"), ] { gix_fs::set_shared_repository_permissions(&path, shared_repository_permissions)?; } Ok(prepared) } /// Return the path to write into a linking file in the directory `base`, pointing to `target`. /// Both inputs must be absolute. If `relative` is set and they share a filesystem root, compute the path /// relative to `base`; otherwise return `target` unchanged, including across Windows drives or UNC shares. /// This only manipulates path components and does not access the filesystem or resolve symbolic links. 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 { 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(()) } /// Create an empty private Git directory under the existing `parent` and return its path. /// Try `basename` first, then append increasing numeric suffixes (`basename1`, `basename2`, …) on collisions. /// Creating each candidate atomically reserves its name, including against concurrent worktree additions. /// Other filesystem errors and suffix overflow are returned; the caller owns cleanup of the created directory. fn reserve_git_dir(parent: &Path, basename: &std::ffi::OsStr) -> io::Result { 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::try_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" ); } }