use crate::process_use::{UseTarget, processes_using};
use crate::single_owner_claim::live_owner_of;
use crate::subvolume_platform::Platform;
use crate::worktree_clean::check_clean_tracked_by_content;
use crate::worktree_convert_journal::{read_plain_file, read_plain_text};
use crate::worktree_path_failure_message::{path_failure, subject_failure};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
pub const GIT_FILE_LIMIT: u64 = 1 << 16;
pub const CONVERTING_SUFFIX: &str = ".cli-git-converting";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Candidate {
pub path: PathBuf,
pub admin: PathBuf,
pub common: PathBuf,
pub git_file: Vec<u8>,
}
pub fn converting_path(path: &Path) -> PathBuf {
let mut name: std::ffi::OsString = path.as_os_str().to_os_string();
name.push(CONVERTING_SUFFIX);
return PathBuf::from(name);
}
pub fn inspect(path: &Path) -> Result<Candidate, String> {
let real: PathBuf = path_failure(std::fs::canonicalize(path), path, "cannot be resolved")?;
let git_path: PathBuf = real.join(".git");
let Ok(metadata) = std::fs::symlink_metadata(&git_path) else {
return Err(format!("{} has no .git entry, so it is not a worktree", real.display()));
};
if metadata.is_dir() {
return Err(format!("{} holds a .git directory: it is a main checkout or a repository, not a linked worktree", real.display()));
}
let git_file: Vec<u8> = path_failure(read_plain_file(&git_path, GIT_FILE_LIMIT), git_path.as_path(), "is unreadable")?;
let Ok(text) = std::str::from_utf8(git_file.as_slice()) else {
return Err(format!("{} is not text", git_path.display()));
};
let Some(named) = text.trim().strip_prefix("gitdir:") else {
return Err(format!("{} has no gitdir line", git_path.display()));
};
let admin: PathBuf = match std::fs::canonicalize(real.join(named.trim())) {
Ok(resolved) => resolved,
Err(error) => return Err(format!("the administrative directory {} cannot be resolved ({error})", named.trim())),
};
let recorded: Vec<u8> = path_failure(read_plain_file(&admin.join("gitdir"), GIT_FILE_LIMIT), admin.as_path(), "has no gitdir file")?;
if Path::new(std::ffi::OsStr::from_bytes(recorded.trim_ascii())) != git_path.as_path() {
return Err(format!("{} does not name {} back, so the registration is stale or points elsewhere", admin.display(), git_path.display()));
}
let common_text: String = path_failure(read_plain_text(&admin.join("commondir")), admin.as_path(), "has no commondir")?;
let common: PathBuf = subject_failure(std::fs::canonicalize(admin.join(common_text.trim())), &"the common directory", "cannot be resolved")?;
return Ok(Candidate { path: real, admin, common, git_file });
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefusalKind {
Locked,
IndexLock,
ConvertingName,
Filesystem,
Nested,
NotQuiet,
Process,
OtherCommand,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct Owned {
pub index_lock: bool,
pub stage: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Refusal {
pub kind: RefusalKind,
pub text: String,
}
fn registered_inside(common: &Path, inside: &Path) -> Result<Vec<PathBuf>, String> {
let directory: PathBuf = common.join("worktrees");
let names = match std::fs::read_dir(&directory) {
Ok(listing) => listing,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(format!("the registered worktrees of {} cannot be listed ({error}), so a worktree inside this one cannot be ruled out", common.display())),
};
let mut found: Vec<PathBuf> = Vec::new();
for item in names {
let name: std::fs::DirEntry = match item {
Ok(entry) => entry,
Err(error) => return Err(format!("the registered worktrees of {} cannot be listed ({error}), so a worktree inside this one cannot be ruled out", common.display())),
};
let recorded: Vec<u8> = match read_plain_file(&name.path().join("gitdir"), GIT_FILE_LIMIT) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(format!("the registration {} cannot be read ({error}), so a worktree inside this one cannot be ruled out", name.path().display())),
};
let Some(worktree) = Path::new(std::ffi::OsStr::from_bytes(recorded.trim_ascii())).parent() else { continue };
if worktree.starts_with(inside) && worktree != inside && worktree.is_dir() {
found.push(worktree.to_path_buf());
}
}
found.sort();
return Ok(found);
}
fn migration_of_main_checkout(common: &Path) -> Option<String> {
if common.file_name() != Some(std::ffi::OsStr::new(".git")) {
return None;
}
let root: &Path = common.parent()?;
let layout = crate::subvolume_preflight::layout_of(root)?;
return match live_owner_of(layout.parent.as_path(), layout.claim_prefix.as_os_str()) {
Ok(None) => None,
Ok(Some(owner)) => Some(format!("process {} is migrating the main checkout {} to a subvolume (it owns {}); wait for it to finish, then run this command again", owner.pid, root.display(), owner.path.display())),
Err(error) => Some(format!("whether the main checkout {} is being migrated cannot be decided ({error})", root.display())),
};
}
fn refuse(reasons: &mut Vec<Refusal>, kind: RefusalKind, text: String) {
reasons.push(Refusal { kind, text });
}
pub fn refusals(platform: &dyn Platform, candidate: &Candidate, owned: Owned, command_line_attributes: bool) -> Vec<Refusal> {
let mut reasons: Vec<Refusal> = Vec::new();
if candidate.admin.join("locked").exists() {
refuse(&mut reasons, RefusalKind::Locked, format!("the worktree is locked ({} exists); unlock it with `git worktree unlock` first", candidate.admin.join("locked").display()));
}
if !owned.index_lock && candidate.admin.join("index.lock").exists() {
refuse(&mut reasons, RefusalKind::IndexLock, format!("{} exists: a Git command is writing the index", candidate.admin.join("index.lock").display()));
}
if let Some(text) = migration_of_main_checkout(candidate.common.as_path()) {
refuse(&mut reasons, RefusalKind::OtherCommand, text);
}
let converting: PathBuf = converting_path(candidate.path.as_path());
if !owned.stage && std::fs::symlink_metadata(&converting).is_ok() {
refuse(&mut reasons, RefusalKind::ConvertingName, format!("{} exists and no journal of this command owns it, so it is left alone; remove it yourself once you know what it is", converting.display()));
}
match platform.filesystem_facts(candidate.path.as_path()) {
Ok((kind, _device)) if kind == crate::fs_kind::FilesystemKind::Btrfs => {}
Ok(_) => refuse(&mut reasons, RefusalKind::Filesystem, format!("{} is not on btrfs", candidate.path.display())),
Err(error) => refuse(&mut reasons, RefusalKind::Filesystem, format!("the filesystem of {} cannot be examined ({error})", candidate.path.display())),
}
match registered_inside(candidate.common.as_path(), candidate.path.as_path()) {
Ok(nested_worktrees) => {
for nested in nested_worktrees {
refuse(&mut reasons, RefusalKind::Nested, format!("the worktree {} is registered inside it; convert or move that one first", nested.display()));
}
}
Err(reason) => refuse(&mut reasons, RefusalKind::Nested, reason),
}
let mut environment = platform.conversion_environment();
if command_line_attributes && environment.alternate_attribute_source.is_none() {
environment.alternate_attribute_source = Some(String::from("--attr-source or -c attr.tree was given on the command line"));
}
if let Err(reason) = check_clean_tracked_by_content(candidate.path.as_path(), candidate.admin.as_path(), candidate.common.as_path(), &environment, owned.index_lock) {
refuse(&mut reasons, RefusalKind::NotQuiet, format!("it is not quiet and unmodified: {reason}"));
}
let scan = processes_using(&UseTarget::Inside(candidate.path.as_path()));
if let Some(why) = scan.unavailable.as_ref() {
refuse(&mut reasons, RefusalKind::Process, format!("the process table cannot be read ({why}), so no process can be ruled out"));
}
for user in &scan.users {
refuse(&mut reasons, RefusalKind::Process, format!("process {} ({}) has its working directory or an open file inside it", user.pid, user.command.as_deref().unwrap_or("unknown")));
}
return reasons;
}