userland/git: git-compatible CLI on v2's gix code (T10)
Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
//! 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 {
|
||||
/// 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 attach(repo: &gix::Repository, p: AttachParams) -> Result<WorktreeInfo> {
|
||||
add_with(
|
||||
repo,
|
||||
AddParams {
|
||||
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 branch = branches::branch_name(&p.branch)?;
|
||||
ensure_available(repo, &branch)?;
|
||||
if let Some(current_path) = repo.workdir() {
|
||||
if 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 = 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: p.branch.clone(),
|
||||
create: Some(!exists),
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user