use crate::add_timing::{begin_work, end_work};
pub use crate::worktree_snapshot_source_changed_paths::{ChangedPaths, changed_paths, plain};
use crate::side_by_side_threads::run_beside_caller;
use crate::config_snapshot::{ConfigEntry, ConfigSnapshot, read_config_snapshot};
use crate::fs_kind::{FilesystemKind, filesystem_kind, is_subvolume_root};
use crate::fs_mount_options::read_mount_facts;
use crate::transaction_git::GitContext;
use crate::worktree_add_plan::AddPlan;
use crate::worktree_clean::{CleanRefusal, CleanRefusalKind, CleanSource, check_clean, dirty_source};
use crate::worktree_identity::WorktreeIdentity;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SnapshotSource {
pub root: PathBuf,
pub git_dir: PathBuf,
pub common_dir: PathBuf,
pub clean: CleanSource,
pub nested: Vec<PathBuf>,
pub config: ConfigSnapshot,
pub trash_ignored: bool,
pub dirty: Option<ChangedPaths>,
}
pub fn registered_roots(common_dir: &Path) -> Vec<PathBuf> {
let Ok(listing) = std::fs::read_dir(common_dir.join("worktrees")) else { return Vec::new() };
let mut roots: Vec<PathBuf> = Vec::new();
for entry in listing.flatten() {
let Ok(recorded) = std::fs::read(entry.path().join("gitdir")) else { continue };
let line: &[u8] = recorded.strip_suffix(b"\n").unwrap_or(recorded.as_slice());
let pointer: PathBuf = PathBuf::from(std::ffi::OsStr::from_bytes(line));
if let Some(root) = pointer.parent() {
roots.push(root.to_path_buf());
}
}
return roots;
}
const SPARSE_KEY: &str = "core.sparsecheckout";
fn configuration_refusal(entries: &[ConfigEntry]) -> Option<String> {
for entry in entries {
if entry.key == SPARSE_KEY {
return Some(String::from("core.sparseCheckout is set"));
}
if entry.scope == "worktree" {
return Some(format!("the worktree-specific configuration sets {}", entry.key));
}
}
return None;
}
fn on_one_btrfs(first: &Path, second: &Path) -> bool {
if filesystem_kind(first) != Ok(FilesystemKind::Btrfs) || filesystem_kind(second) != Ok(FilesystemKind::Btrfs) {
return false;
}
let (Ok(Some(one)), Ok(Some(other))) = (read_mount_facts(first), read_mount_facts(second)) else { return false };
return one.source == other.source;
}
fn nested_ordinary_worktree(nested: &[PathBuf]) -> Option<PathBuf> {
for path in nested {
if path.exists() && !is_subvolume_root(path.as_path()).unwrap_or(false) {
return Some(path.clone());
}
}
return None;
}
fn nested_through_link(nested: &[PathBuf]) -> Option<PathBuf> {
for path in nested {
if path.exists() && std::fs::canonicalize(path.as_path()).ok().as_ref() != Some(path) {
return Some(path.clone());
}
}
return None;
}
fn worktree_of(identity: &WorktreeIdentity) -> Result<(&PathBuf, &PathBuf, &PathBuf), String> {
return match identity {
WorktreeIdentity::MainWorktree { worktree_root, git_dir, common_dir } | WorktreeIdentity::LinkedWorktree { worktree_root, git_dir, common_dir } => Ok((worktree_root, git_dir, common_dir)),
WorktreeIdentity::BareRepository { .. } | WorktreeIdentity::OutsideWorktree => Err(String::from("there is no worktree to copy from")),
};
}
pub fn snapshot_refusal(identity: &WorktreeIdentity, plan: &AddPlan) -> Option<String> {
let (root, _git_dir, _common_dir): (&PathBuf, &PathBuf, &PathBuf) = match worktree_of(identity) {
Ok(found) => found,
Err(reason) => return Some(reason),
};
let Some(parent) = plan.destination.parent() else { return Some(String::from("the destination has no parent directory")) };
if !is_subvolume_root(root.as_path()).unwrap_or(false) {
return Some(format!("{} is not a btrfs subvolume", root.display()));
}
if !on_one_btrfs(root.as_path(), parent) {
return Some(format!("{} and {} are not on one btrfs filesystem", root.display(), parent.display()));
}
return None;
}
fn device_of_path(path: &Path) -> Option<u64> {
return match std::fs::metadata(path) {
Ok(found) => Some(found.dev()),
Err(_unreadable) => None,
};
}
fn may_clone(path: &Path) -> bool {
if matches!(filesystem_kind(path), Ok(FilesystemKind::Btrfs | FilesystemKind::Overlayfs)) {
return true;
}
return crate::fs_kind::is_xfs(path).unwrap_or(false);
}
pub fn clone_refusal(identity: &WorktreeIdentity, plan: &AddPlan) -> Option<String> {
let (root, _git_dir, _common_dir): (&PathBuf, &PathBuf, &PathBuf) = match worktree_of(identity) {
Ok(found) => found,
Err(reason) => return Some(reason),
};
let Some(parent) = plan.destination.parent() else { return Some(String::from("the destination has no parent directory")) };
if !may_clone(root.as_path()) || !may_clone(parent) {
return Some(String::from("the filesystem does not share data blocks between files"));
}
let one_domain: bool = if filesystem_kind(root.as_path()) == Ok(FilesystemKind::Btrfs) {
on_one_btrfs(root.as_path(), parent)
} else {
device_of_path(root.as_path()) == device_of_path(parent)
};
return if one_domain { None } else { Some(format!("{} and {} are on different filesystems", root.display(), parent.display())) };
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RefusalKind {
Unclean(CleanRefusalKind),
Unsupported,
StatusFailed,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Refusal {
pub reason: String,
pub kind: RefusalKind,
}
impl Refusal {
fn unsupported(reason: impl Into<String>) -> Refusal {
return Refusal { reason: reason.into(), kind: RefusalKind::Unsupported };
}
}
pub fn source_refusal(context: &GitContext, identity: &WorktreeIdentity) -> Result<SnapshotSource, Refusal> {
return checked_source(context, identity, false);
}
pub fn source_or_dirty(context: &GitContext, identity: &WorktreeIdentity) -> Result<SnapshotSource, Refusal> {
return checked_source(context, identity, true);
}
fn read_configuration_timed((context, root): (&GitContext, &Path)) -> Option<ConfigSnapshot> {
let began: std::time::Instant = begin_work();
let read: Option<ConfigSnapshot> = read_config_snapshot(context, root);
end_work("readConfig", began);
return read;
}
fn trash_location_ignored_timed((context, root, git_dir, common_dir): (&GitContext, &Path, &Path, &Path)) -> bool {
let began: std::time::Instant = begin_work();
let ignored: bool = git_dir == common_dir && crate::worktree_trash::trash_location_is_ignored(context, root);
end_work("trashIgnored", began);
return ignored;
}
fn clean_check_of_source((context, root, git_dir, common_dir): (&GitContext, &Path, &Path, &Path)) -> Result<CleanSource, CleanRefusal> {
return check_clean(context, root, git_dir, common_dir);
}
type TrashAndCleanVerdicts = (std::thread::Result<bool>, Result<CleanSource, CleanRefusal>);
fn check_trash_and_cleanliness(state: (&GitContext, &Path, &Path, &Path)) -> TrashAndCleanVerdicts {
return run_beside_caller((state, trash_location_ignored_timed), (state, clean_check_of_source));
}
fn checked_source(context: &GitContext, identity: &WorktreeIdentity, allow_dirty: bool) -> Result<SnapshotSource, Refusal> {
let (root, git_dir, common_dir): (&PathBuf, &PathBuf, &PathBuf) = worktree_of(identity).map_err(Refusal::unsupported)?;
if git_dir.join("info/sparse-checkout").exists() || common_dir.join("info/sparse-checkout").exists() {
return Err(Refusal::unsupported("the source has a sparse checkout"));
}
let mut nested: Vec<PathBuf> = Vec::new();
for path in registered_roots(common_dir.as_path()) {
if path != *root && path.starts_with(root) {
nested.push(path);
}
}
let (config_joined, (trash_joined, checked)): (std::thread::Result<Option<ConfigSnapshot>>, TrashAndCleanVerdicts) = run_beside_caller(
((context, root.as_path()), read_configuration_timed),
((context, root.as_path(), git_dir.as_path(), common_dir.as_path()), check_trash_and_cleanliness),
);
let read_config: Option<ConfigSnapshot> = config_joined.unwrap_or(None);
let trash_ignored: bool = trash_joined.unwrap_or(false);
let Some(snapshot) = read_config else {
return Err(Refusal::unsupported("the Git configuration could not be read"));
};
if let Some(reason) = configuration_refusal(snapshot.entries()) {
return Err(Refusal::unsupported(reason));
}
let (clean, dirty): (CleanSource, Option<ChangedPaths>) = match checked {
Ok(found) => (found, None),
Err(why) if allow_dirty && restorable_here(identity) && why.kind.restorable() => {
let found = dirty_source(git_dir.as_path(), common_dir.as_path(), why).map_err(unclean)?;
let changed: ChangedPaths = changed_paths(context, root.as_path()).map_err(status_failed)?;
if changed.attributes_changed {
return Err(Refusal::unsupported("a .gitattributes file is among the uncommitted changes, and its rules could change how the restore writes other files"));
}
crate::diagnostic_log::debug("sourceOrDirty", format!("{} is snapshotted with {} uncommitted paths, the first {:?}; the restore follows ({})", root.display(), changed.count, changed.first, found.why).as_str());
(found.source, Some(changed))
}
Err(why) => return Err(unclean(why)),
};
return Ok(SnapshotSource { root: root.clone(), git_dir: git_dir.clone(), common_dir: common_dir.clone(), clean, nested, config: snapshot, trash_ignored, dirty });
}
fn unclean(why: CleanRefusal) -> Refusal {
return Refusal { reason: why.reason, kind: RefusalKind::Unclean(why.kind) };
}
fn status_failed(reason: String) -> Refusal {
return Refusal { reason, kind: RefusalKind::StatusFailed };
}
fn restorable_here(identity: &WorktreeIdentity) -> bool {
return matches!(identity, WorktreeIdentity::MainWorktree { worktree_root, git_dir, .. } if *git_dir == worktree_root.join(".git"));
}
pub fn common_source(context: &GitContext, identity: &WorktreeIdentity) -> Result<SnapshotSource, String> {
return match source_refusal(context, identity) {
Ok(source) => Ok(source),
Err(refusal) => Err(refusal.reason),
};
}
fn ordinary_worktree_reason(path: PathBuf) -> String {
return format!("the worktree {} inside the source is an ordinary directory; `git monochromatic-git-policy-cli migrate-to-subvolume` makes it a subvolume", path.display());
}
pub fn snapshot_blocker(source: &SnapshotSource) -> Option<String> {
if let Some(path) = nested_through_link(source.nested.as_slice()) {
return Some(format!("the worktree {} inside the source is reached through a symbolic link", path.display()));
}
if source.git_dir == source.common_dir {
return None;
}
return nested_ordinary_worktree(source.nested.as_slice()).map(ordinary_worktree_reason);
}
pub fn choose_snapshot_source(context: &GitContext, identity: &WorktreeIdentity, plan: &AddPlan) -> Result<SnapshotSource, String> {
if let Some(reason) = snapshot_refusal(identity, plan) {
return Err(reason);
}
let source: SnapshotSource = common_source(context, identity)?;
return match snapshot_blocker(&source) {
Some(reason) => Err(reason),
None => Ok(source),
};
}
#[cfg(test)]
#[path = "worktree_snapshot_source_tests.rs"]
mod tests;