//! Linked worktrees use ordinary Git files. Callers hold only the common-dir lock. use crate::Result; use crate::{ branches, repo::{self, error}, }; use std::{ collections::BTreeSet, fs, io::Write, path::{Path, PathBuf}, }; /// Attach a linked worktree, or adopt existing bytes onto an unborn branch. #[derive(serde::Serialize, serde::Deserialize)] pub(crate) struct AddParams { pub detached: Option, /// Repository name or absolute worktree/bare path under /repos/ or /plugins/. pub repo: String, /// Absolute worktree path under /plugins/ or /repos/, without dot segments. pub path: String, /// Short local branch name; an existing branch must be checked out nowhere. pub branch: String, /// Starting revision for a new branch, default HEAD. Not used for existing branches; forbidden with adopt. pub from: Option, /// Attach an existing directory (including empty) to a new unborn branch; only .git is written. pub adopt: Option, } /// Delete a linked worktree and its private metadata, never the common repository. #[derive(serde::Serialize, serde::Deserialize)] pub(crate) struct RemoveParams { /// Absolute linked-worktree root under /plugins/ or /repos/. pub repo: String, /// Discard dirty, untracked and pending operation state, default false. pub force: Option, } /// Linked worktree identity. #[derive(serde::Serialize, serde::Deserialize)] pub(crate) struct WorktreeInfo { /// Absolute worktree root. pub path: String, /// Private Git metadata directory. pub admin: String, /// Checkout feature warnings, empty for adoption and removal. pub warnings: Vec, } fn canonical(path: &Path) -> Result { fs::canonicalize(path).map_err(|e| error("worktree path", format!("{}: {e}", path.display()))) } fn read(path: &Path) -> Result { fs::read_to_string(path) .map_err(|e| error("worktree metadata", format!("{}: {e}", path.display()))) } fn main_worktree(common: &Path) -> Result> { let main = gix::open_opts(common, gix::open::Options::default().open_path_as_is(true)) .map_err(|e| error("main worktree", e))?; Ok(main.workdir().map(Path::to_owned)) } /// Scan symbolic HEADs, including unborn branches. Ignore only this handle's private HEAD. /// A bare common HEAD is a default branch, not a checked-out worktree. pub(crate) fn ensure_available(repo: &gix::Repository, branch: &str) -> Result<()> { let common = canonical(repo.common_dir())?; let current = canonical(repo.git_dir())?; let mut heads = Vec::new(); if let Some(path) = main_worktree(&common)? { heads.push((common.clone(), path)); } let entries = match fs::read_dir(common.join("worktrees")) { Ok(entries) => Some(entries), Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => return Err(error("worktree scan", e)), }; if let Some(entries) = entries { for entry in entries { let entry = entry.map_err(|e| error("worktree scan", e))?; if !entry .file_type() .map_err(|e| error("worktree scan", e))? .is_dir() { continue; } let admin = entry.path(); let gitfile = read(&admin.join("gitdir"))?; let gitfile = PathBuf::from(gitfile.trim()); let path = gitfile .parent() .ok_or_else(|| error("worktree gitdir", "missing worktree path"))? .to_owned(); heads.push((admin, path)); } } for (admin, path) in heads { if canonical(&admin)? == current { continue; } let head = read(&admin.join("HEAD"))?; if head.trim().strip_prefix("ref: ") == Some(branch) { return Err(error( "branch checked out", format!("{branch} is checked out at {}", path.display()), )); } } Ok(()) } fn absolute(path: &str) -> Result { if !path.starts_with('/') || path.contains(['\r', '\n']) { return Err(error( "worktree path", "expected an absolute path without line breaks", )); } repo::repo_path(path) } fn separate(path: &Path, common: &Path) -> Result<()> { if path.starts_with(common) || common.starts_with(path) { return Err(error( "worktree path", "worktree and common repository must not contain one another", )); } Ok(()) } fn write_new(path: &Path, bytes: &str) -> Result<()> { let mut file = fs::OpenOptions::new() .write(true) .create_new(true) .open(path) .map_err(|e| error("worktree create", format!("{}: {e}", path.display())))?; if let Err(e) = file.write_all(bytes.as_bytes()) { drop(file); fs::remove_file(path).map_err(|cleanup| error("worktree write cleanup", cleanup))?; return Err(error("worktree write", e)); } Ok(()) } /// Attach existing bytes to an existing branch without checking out any files. #[derive(serde::Serialize, serde::Deserialize)] pub(crate) struct AttachParams { /// Bare common repository path. pub repo: String, /// Existing worktree directory; only .git metadata is written. pub path: String, /// Existing short local branch name. pub branch: String, } pub(crate) fn prune(repo: &gix::Repository) -> Result<()> { let base = repo.common_dir().join("worktrees"); if !base.exists() { return Ok(()); } for entry in fs::read_dir(base).map_err(|e| error("worktree prune", e))? { let dir = entry.map_err(|e| error("worktree prune", e))?.path(); if dir.join("locked").exists() { continue; } let gitdir = read(&dir.join("gitdir"))?; if !Path::new(gitdir.trim()).exists() { fs::remove_dir_all(dir).map_err(|e| error("worktree prune", e))?; } } Ok(()) } pub(crate) fn attach(repo: &gix::Repository, p: AttachParams) -> Result { add_with( repo, AddParams { detached: None, repo: p.repo, path: p.path, branch: p.branch, from: None, adopt: None, }, true, ) } pub(crate) fn add(repo: &gix::Repository, p: AddParams) -> Result { add_with(repo, p, false) } fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result { let path = absolute(&p.path)?; let common = canonical(repo.common_dir())?; separate(&path, &common)?; let detached = p.detached.unwrap_or(false); let branch = branches::branch_name(&p.branch)?; if !detached { ensure_available(repo, &branch)?; } if let Some(current_path) = repo.workdir() { if !detached && crate::head_info(repo)?.branch.as_deref() == Some(&branch) { return Err(error( "branch checked out", format!("{branch} is checked out at {}", current_path.display()), )); } } let exists = !detached && repo .try_find_reference(branch.as_str()) .map_err(|e| error("worktree branch", e))? .is_some(); let adopt = p.adopt.unwrap_or(false); if adopt && (exists || p.from.is_some()) { return Err(error( "worktree adopt", "requires a new unborn branch and no from revision", )); } let existed = path.try_exists().map_err(|e| error("worktree path", e))?; if existed && !path.is_dir() { return Err(error("worktree path", "not a directory")); } let nonempty = if existed { fs::read_dir(&path) .map_err(|e| error("worktree directory", e))? .next() .transpose() .map_err(|e| error("worktree directory", e))? .is_some() } else { false }; if adopt && !existed { return Err(error("worktree adopt", "requires an existing directory")); } if keep_files && !exists { return Err(error("worktree attach", "requires an existing branch")); } if keep_files && !existed { return Err(error("worktree attach", "requires an existing directory")); } if !adopt && !keep_files && nonempty { return Err(error( "worktree path", format!("{} is nonempty", path.display()), )); } if path.join(".git").exists() { return Err(error("worktree adopt", ".git already exists")); } let id_path = if path.file_name().is_some_and(|s| s == "source") { path.parent().unwrap_or(&path) } else { &path }; let id = id_path .file_name() .ok_or_else(|| error("worktree id", "missing folder name"))?; let admin = common.join("worktrees").join(id); // Resolve before any mutation. Existing branches ignore from, matching checkout semantics. let start = if adopt { None } else { Some(branches::resolve( repo, if exists { &branch } else { p.from.as_deref().unwrap_or("HEAD") }, )?) }; fs::create_dir_all(common.join("worktrees")).map_err(|e| error("worktree admin", e))?; fs::create_dir(&admin) .map_err(|e| error("worktree id clash", format!("{}: {e}", admin.display())))?; let mut gitfile_created = false; let result: Result = (|| { fs::create_dir_all(&path).map_err(|e| error("worktree mkdir", e))?; separate(&canonical(&path)?, &common)?; // Start detached to let checkout create/attach the branch after index publication. fs::write( admin.join("HEAD"), start .map(|id| format!("{id}\n")) .unwrap_or_else(|| format!("ref: {branch}\n")), ) .map_err(|e| error("worktree HEAD", e))?; fs::write(admin.join("commondir"), "../..\n") .map_err(|e| error("worktree commondir", e))?; fs::write(admin.join("gitdir"), format!("{}/.git\n", path.display())) .map_err(|e| error("worktree gitdir", e))?; write_new( &path.join(".git"), &format!("gitdir: {}\n", admin.display()), )?; gitfile_created = true; let linked = repo::open(&p.path)?; let warnings = if let Some(id) = start { let tree = linked .find_commit(id) .map_err(|e| error("worktree commit", e))? .tree_id() .map_err(|e| error("worktree tree", e))? .detach(); if keep_files { let state = gix::index::State::from_tree(&tree, &linked.objects, Default::default()) .map_err(|e| error("worktree attach index", e))?; crate::staging::publish(&linked, state)?; fs::write(admin.join("HEAD"), format!("ref: {branch}\n")) .map_err(|e| error("worktree attach HEAD", e))?; return Ok(WorktreeInfo { path: p.path.clone(), admin: admin.to_string_lossy().into_owned(), warnings: vec![], }); } let mut warnings = crate::checkout::write_tree_to_worktree(&linked, None, tree, false)?; warnings.extend( branches::checkout( &linked, branches::CheckoutParams { repo: p.path.clone(), target: if detached { id.to_string() } else { p.branch.clone() }, create: Some(!exists && !detached), force: None, }, )? .warnings, ); warnings } else { Vec::new() }; Ok(WorktreeInfo { path: p.path.clone(), admin: admin.to_string_lossy().into_owned(), warnings, }) })(); if result.is_err() { // Adoption never removes source bytes. Non-adoption started empty, so all leaves are ours. let cleanup = (|| -> std::io::Result<()> { if adopt || keep_files { if gitfile_created { fs::remove_file(path.join(".git"))?; } } else if gitfile_created { fs::remove_dir_all(&path)?; if existed { fs::create_dir(&path)?; } } else if !existed && path.is_dir() { fs::remove_dir(&path)?; } Ok(()) })(); let admin_cleanup = fs::remove_dir_all(&admin); if let Err(e) = cleanup.and(admin_cleanup) { return Err(error( "worktree rollback", format!("{}; original error: {}", e, result.err().unwrap().message), )); } } result } pub(crate) fn remove(repo: &gix::Repository, p: RemoveParams) -> Result { let path = canonical(&absolute(&p.repo)?)?; let common = canonical(repo.common_dir())?; let admin = canonical(repo.git_dir())?; separate(&path, &common)?; // Only linked metadata directly under common/worktrees is removable, even with force. if admin.parent() != Some(common.join("worktrees").as_path()) || !path.join(".git").is_file() { return Err(error( "worktree remove", "cannot remove the original/main repository worktree", )); } let backlink = PathBuf::from(read(&admin.join("gitdir"))?.trim()); if canonical(&backlink)? != canonical(&path.join(".git"))? { return Err(error( "worktree remove", "admin gitdir does not point back to this worktree", )); } if !p.force.unwrap_or(false) { let s = crate::changes::status(repo)?; let mut paths: BTreeSet<_> = s .staged .into_iter() .chain(s.unstaged) .flat_map(|c| std::iter::once(c.path).chain(c.old_path)) .chain(s.untracked) .chain(s.conflicted) .collect(); if let Err(e) = crate::merge::ensure_idle(repo) { paths.insert(e.message); } if repo.git_dir().join("index.lock").exists() { paths.insert("index.lock".into()); } // Include ignored untracked files too: removing a worktree must not silently lose them. let index = repo .index_or_empty() .map_err(|e| error("worktree index", e))?; let tracked: BTreeSet<_> = index .entries() .iter() .map(|e| e.path(&index).to_vec()) .collect(); collect_untracked(&path, &path, &tracked, &mut paths)?; if !paths.is_empty() { return Err(error( "worktree remove dirty/untracked/pending paths", paths.into_iter().collect::>().join(", "), )); } } fs::remove_dir_all(&path).map_err(|e| error("worktree remove files", e))?; fs::remove_dir_all(&admin).map_err(|e| error("worktree remove admin", e))?; if let Some(parent) = path.parent() { if parent.parent() == Some(Path::new("/plugins")) && parent .file_name() .is_some_and(|s| s.to_string_lossy().starts_with("wt-")) { match fs::remove_dir(parent) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {} Err(e) => return Err(error("worktree remove empty parent", e)), } } } Ok(WorktreeInfo { path: p.repo, admin: admin.to_string_lossy().into_owned(), warnings: vec![], }) } fn collect_untracked( root: &Path, dir: &Path, tracked: &BTreeSet>, paths: &mut BTreeSet, ) -> Result<()> { for entry in fs::read_dir(dir).map_err(|e| error("worktree directory", e))? { let entry = entry.map_err(|e| error("worktree directory", e))?; let path = entry.path(); if path == root.join(".git") { continue; } if entry .file_type() .map_err(|e| error("worktree directory", e))? .is_dir() { collect_untracked(root, &path, tracked, paths)?; } else { let relative = path .strip_prefix(root) .map_err(|e| error("worktree path", e))? .to_string_lossy(); if !tracked.contains(relative.as_bytes()) { paths.insert(relative.into_owned()); } } } Ok(()) }