475 lines
17 KiB
Rust
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(())
|
|
}
|