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, GitRequest, run_git};
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>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChangedPaths {
pub count: usize,
pub first: Option<String>,
pub attributes_changed: bool,
}
pub fn plain(text: &str) -> String {
return text.chars().map(|character: char| { return if character.is_control() { '?' } else { character }; }).collect();
}
impl ChangedPaths {
pub fn has_changes(&self) -> bool {
return self.count > 0;
}
}
pub fn changed_paths(context: &GitContext, root: &Path) -> Result<ChangedPaths, String> {
let ask = |arguments: &[&str]| -> Result<Vec<u8>, String> {
let words: String = arguments.iter().skip(1).copied().take(1).collect::<Vec<&str>>().join(" ");
let request: GitRequest = GitRequest { cwd: root.to_path_buf(), arguments: arguments.iter().map(|word: &&str| { return (*word).into(); }).collect(), without_prefix: true, ..GitRequest::default() };
return match run_git(context, &request) {
Ok(found) if found.code == Some(0) => Ok(found.stdout),
Ok(found) => {
let said: String = String::from_utf8_lossy(found.stderr.as_slice()).trim().to_string();
let code: String = found.code.map_or_else(|| { return String::from("was stopped by a signal"); }, |number: i32| { return format!("ended with exit code {number}"); });
crate::diagnostic_log::debug("changedPaths", format!("Git could not list the changes (`git {words}` {code}: {said})").as_str());
Err(if said.is_empty() { format!("`git {words}` {code} and said nothing") } else { format!("`git {words}` {code}: {}", plain(said.as_str())) })
}
Err(error) => {
crate::diagnostic_log::debug("changedPaths", format!("Git could not list the changes ({error})").as_str());
Err(format!("`git {words}` could not be started ({error})"))
}
};
};
let listing: Vec<u8> = ask(&["--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--no-renames"])?;
let untracked_attributes: Vec<u8> = ask(&["--no-optional-locks", "ls-files", "--others", "--exclude-standard", "-z", "--", ":(glob)**/.gitattributes"])?;
let paths: Vec<&[u8]> = listing.split(|byte: &u8| { return *byte == 0; }).filter(|entry: &&[u8]| { return !entry.is_empty(); }).filter_map(|entry: &[u8]| { return entry.get(3..); }).collect();
let attributes_changed: bool = !untracked_attributes.is_empty() || paths.iter().any(|path: &&[u8]| { return *path == b".gitattributes" || path.ends_with(b"/.gitattributes"); });
return Ok(ChangedPaths { count: paths.len(), first: paths.first().map(|path: &&[u8]| { return plain(String::from_utf8_lossy(path).as_ref()); }), attributes_changed });
}
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> {
return nested
.iter()
.find(|path: &&PathBuf| { return path.exists() && !is_subvolume_root(path.as_path()).unwrap_or(false); })
.cloned();
}
fn nested_through_link(nested: &[PathBuf]) -> Option<PathBuf> {
return nested
.iter()
.find(|path: &&PathBuf| { return path.exists() && std::fs::canonicalize(path.as_path()).ok().as_ref() != Some(*path); })
.cloned();
}
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 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 {
std::fs::metadata(root.as_path()).map(|found: std::fs::Metadata| { return found.dev(); }).ok() == std::fs::metadata(parent).map(|found: std::fs::Metadata| { return found.dev(); }).ok()
};
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 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 nested: Vec<PathBuf> = registered_roots(common_dir.as_path()).into_iter().filter(|path: &PathBuf| { return path != root && path.starts_with(root); }).collect();
let (read_config, checked, trash_ignored): (Option<ConfigSnapshot>, Result<CleanSource, CleanRefusal>, bool) = std::thread::scope(|scope| {
let config = scope.spawn(|| { return crate::add_timing::work("readConfig", || { return read_config_snapshot(context, root.as_path()); }); });
let trash = scope.spawn(|| { return crate::add_timing::work("trashIgnored", || { return git_dir == common_dir && crate::worktree_trash::trash_location_is_ignored(context, root.as_path()); }); });
let checked = check_clean(context, root.as_path(), git_dir.as_path(), common_dir.as_path());
return (config.join().unwrap_or(None), checked, trash.join().unwrap_or(false));
});
let snapshot: ConfigSnapshot = read_config.ok_or_else(|| { return Refusal::unsupported("the Git configuration could not be read"); })?;
if let Some(reason) = configuration_refusal(snapshot.entries()) {
return Err(Refusal::unsupported(reason));
}
let unclean = |why: CleanRefusal| -> Refusal { return Refusal { reason: why.reason, kind: RefusalKind::Unclean(why.kind) }; };
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(|reason: String| { return Refusal { reason, kind: RefusalKind::StatusFailed }; })?;
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 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 source_refusal(context, identity).map_err(|refusal: Refusal| { return refusal.reason; });
}
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(|path: PathBuf| { return format!("the worktree {} inside the source is an ordinary directory; `git cli-git migrate-to-subvolume` makes it a subvolume", path.display()); });
}
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;