Files
sausage/userland/git/src/worktrees.rs
T

475 lines
17 KiB
Rust

//! 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<bool>,
/// 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<String>,
/// Attach an existing directory (including empty) to a new unborn branch; only .git is written.
pub adopt: Option<bool>,
}
/// 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<bool>,
}
/// 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<String>,
}
fn canonical(path: &Path) -> Result<PathBuf> {
fs::canonicalize(path).map_err(|e| error("worktree path", format!("{}: {e}", path.display())))
}
fn read(path: &Path) -> Result<String> {
fs::read_to_string(path)
.map_err(|e| error("worktree metadata", format!("{}: {e}", path.display())))
}
fn main_worktree(common: &Path) -> Result<Option<PathBuf>> {
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<PathBuf> {
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<WorktreeInfo> {
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<WorktreeInfo> {
add_with(repo, p, false)
}
fn add_with(repo: &gix::Repository, p: AddParams, keep_files: bool) -> Result<WorktreeInfo> {
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<WorktreeInfo> = (|| {
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<WorktreeInfo> {
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::<Vec<_>>().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<Vec<u8>>,
paths: &mut BTreeSet<String>,
) -> 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(())
}