use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use sandfs_core::{Kind, VPath, Workspace};
use crate::git;
use crate::persist::{Restored, Snapshot};
use crate::store::{self, Base, Home, ROOT, check_name};
const MARKER: &str = ".git/sandfs-workspace";
const PRIVATE: &str = "folder-git";
const SYNCED: &str = "folder-synced";
pub fn clones_into(top: &Path, dir: &Path) -> bool {
let Ok(dir) = crate::ops::resolve_new(dir) else { return false };
let Some(at) = dir.ancestors().find(|at| at.is_dir()) else { return false };
if at.starts_with(top.canonicalize().unwrap_or_else(|_| top.to_path_buf())) {
return false;
}
let Some(file) = [".git/HEAD", ".git"].into_iter().map(|name| top.join(name)).find(|path| path.is_file()) else {
return false;
};
let probe = at.join(format!(".sandfs-clone-check-{}", std::process::id()));
let cloned = reflink_copy::reflink(&file, &probe).is_ok();
if cloned {
let _ = std::fs::remove_file(&probe);
}
cloned
}
pub fn make(
home: &Home,
name: &str,
ws: &Workspace,
top: &Path,
dir: &Path,
index_from: Option<&Path>,
) -> Result<PathBuf> {
let target = crate::ops::resolve_new(dir)?;
if target.is_dir() && std::fs::read_dir(&target)?.next().is_some() {
bail!("{} is not empty; a fork kept as a folder needs a new or empty one", target.display());
}
let home_dir = crate::resolve_new(home.root())?;
if home_dir.starts_with(&target) || target.starts_with(&home_dir) {
bail!("{} overlaps sandfs's own state ({}); pick another folder", target.display(), home_dir.display());
}
std::fs::create_dir_all(&target).with_context(|| format!("creating {}", target.display()))?;
let dir = target.canonicalize()?;
let made = (|| {
let mut record = home.record(name)?;
let Base::Git { repo, commit, branch } = record.base.clone() else {
bail!("only a fork of a git repository can be kept as a folder");
};
record.folder = Some(dir.clone());
home.set_record(name, &record)?;
clone_files(ws, top, &dir)?;
let private = home.workspace_dir(name).join(PRIVATE);
git::make_folder(ws, (&repo, &commit, branch.as_deref()), &dir, &private, index_from)?;
std::fs::write(dir.join(MARKER), name).context("marking the folder")?;
let mut paths = changed_paths(ws)?;
paths.extend(git::folder_changes(&private, &dir)?);
let before = signature(&dir, &paths);
if holds(ws, &dir, &paths)? && signature(&dir, &paths) == before {
write_synced(&home.workspace_dir(name).join(SYNCED), before, &paths)?;
}
Ok(())
})();
if let Err(err) = made {
let _ = store::remove_copy(&dir);
return Err(err);
}
Ok(dir)
}
fn clone_files(ws: &Workspace, top: &Path, dir: &Path) -> Result<()> {
let root = VPath::parse(ROOT)?;
let mut folders = vec![(root.clone(), dir.to_path_buf())];
while let Some((at, to)) = folders.pop() {
for entry in ws.list(at.as_str())? {
if at == root && entry.name == ".git" {
continue;
}
let path = at.join(&entry.name)?;
let rel = path.strip_prefix(&root).unwrap_or_default().join("/");
let (host, copy) = (top.join(&rel), to.join(&entry.name));
match entry.kind {
Kind::Dir => {
std::fs::create_dir(©).with_context(|| format!("creating {}", copy.display()))?;
folders.push((path, copy));
}
Kind::Symlink => store::symlink(Path::new(&ws.read_link(path.as_str())?), ©)?,
Kind::File { executable } => {
let size = ws.stat(path.as_str())?.size;
let same = std::fs::symlink_metadata(&host)
.is_ok_and(|meta| meta.is_file() && meta.len() == size && is_executable(&meta) == executable);
if same && reflink_copy::reflink_or_copy(&host, ©).is_ok() {
store::keep_times(&host, ©)?;
} else {
std::fs::write(©, ws.read(path.as_str())?)
.with_context(|| format!("writing {}", copy.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if executable { 0o755 } else { 0o644 };
std::fs::set_permissions(©, std::fs::Permissions::from_mode(mode))?;
}
}
}
}
}
}
Ok(())
}
pub fn sync(home: &Home, name: &str, dir: &Path) -> Result<Option<Workspace>> {
if find(home, dir).as_deref() != Some(name) {
bail!("{name}'s folder {} is gone; `sandfs rm {name}` deletes the workspace", dir.display());
}
let _writer = home.lock(name, &format!("sandfs, reading the changes in {}", dir.display()))?;
let listed = git::folder_changes(&home.workspace_dir(name).join(PRIVATE), dir)?;
let synced = home.workspace_dir(name).join(SYNCED);
if let Some((signature, mut paths)) = read_synced(&synced) {
paths.extend(listed.iter().cloned());
if self::signature(dir, &paths) == signature {
return Ok(None);
}
}
let base = home.base(name)?;
let mut opened = home.open_over(name, &base)?;
let root = VPath::parse(ROOT)?;
let mut candidates = changed_paths(&opened.ws)?;
candidates.extend(listed);
let before = signature(dir, &candidates);
let (mut gone, mut changed) = (Vec::new(), Vec::new());
for rel in &candidates {
let (rel, at) = (rel.clone(), format!("{ROOT}/{rel}"));
match git::in_folder(dir, &rel)? {
None if opened.ws.exists(&at) => gone.push(rel),
None => {}
Some(meta) if !meta.is_file() && !meta.is_dir() && !meta.file_type().is_symlink() => {}
Some(meta) if !same(&opened.ws, &at, &meta, &dir.join(&rel))? => changed.push(rel),
Some(_) => {}
}
}
if !gone.is_empty() || !changed.is_empty() {
gone.append(&mut changed);
let Restored { mut times, mut dirs } = std::mem::take(&mut opened.restored);
git::copy_changed(&mut opened.ws, dir, &gone, &mut times, &mut dirs)?;
opened.saved.save(&Snapshot { ws: opened.ws.fork(), times, dirs }, &root, true)?;
}
if signature(dir, &candidates) != before {
let _ = std::fs::remove_file(&synced);
} else {
write_synced(&synced, before, &candidates)?;
}
Ok(Some(base))
}
fn changed_paths(ws: &Workspace) -> Result<BTreeSet<String>> {
let root = VPath::parse(ROOT)?;
let git_dir = VPath::parse(&format!("{ROOT}/.git"))?;
Ok(ws
.changes()?
.into_iter()
.filter(|change| !change.path.starts_with(&git_dir))
.map(|change| change.path.strip_prefix(&root).unwrap_or_default().join("/"))
.collect())
}
fn write_synced(synced: &Path, signature: String, paths: &BTreeSet<String>) -> Result<()> {
let mut data = signature;
for path in paths {
data.push('\0');
data.push_str(path);
}
std::fs::write(synced, data).context("writing what the folder holds")
}
fn read_synced(synced: &Path) -> Option<(String, BTreeSet<String>)> {
let data = std::fs::read_to_string(synced).ok()?;
let mut parts = data.split('\0');
let signature = parts.next()?.to_owned();
Some((signature, parts.map(str::to_owned).collect()))
}
fn signature(dir: &Path, paths: &BTreeSet<String>) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for rel in paths {
rel.hash(&mut hasher);
let Ok(Some(meta)) = git::in_folder(dir, rel) else {
0u8.hash(&mut hasher);
continue;
};
(meta.len(), meta.modified().ok(), meta.file_type().is_symlink()).hash(&mut hasher);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
(meta.ino(), meta.mode(), meta.ctime(), meta.ctime_nsec()).hash(&mut hasher);
}
}
format!("{:016x}", hasher.finish())
}
fn holds(ws: &Workspace, dir: &Path, paths: &BTreeSet<String>) -> Result<bool> {
for rel in paths {
let at = format!("{ROOT}/{rel}");
let held = match git::in_folder(dir, rel)? {
None => !ws.exists(&at),
Some(meta) => same(ws, &at, &meta, &dir.join(rel))?,
};
if !held {
return Ok(false);
}
}
Ok(true)
}
fn same(ws: &Workspace, at: &str, meta: &std::fs::Metadata, host: &Path) -> Result<bool> {
let Ok(stat) = ws.stat(at) else { return Ok(false) };
Ok(match stat.kind {
Kind::Dir => meta.is_dir(),
Kind::Symlink => {
meta.file_type().is_symlink() && Some(ws.read_link(at)?.as_str()) == std::fs::read_link(host)?.to_str()
}
Kind::File { executable } => {
meta.is_file()
&& meta.len() == stat.size
&& is_executable(meta) == executable
&& ws.read(at)? == std::fs::read(host)?
}
})
}
fn is_executable(meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
return std::os::unix::fs::PermissionsExt::mode(&meta.permissions()) & 0o111 != 0;
#[cfg(not(unix))]
{
let _ = meta;
false
}
}
pub fn private_index(home: &Home, name: &str) -> PathBuf {
home.workspace_dir(name).join(PRIVATE).join("index")
}
pub fn find(home: &Home, what: &Path) -> Option<String> {
let dir = what.canonicalize().ok()?;
let name = std::fs::read_to_string(dir.join(MARKER)).ok()?;
check_name(&name).ok()?;
(home.record(&name).ok()?.folder? == dir).then_some(name)
}
pub fn git_settings(home: &Home, name: &str, dir: &Path) -> Vec<String> {
let mut found = Vec::new();
let made = std::fs::read(home.workspace_dir(name).join(PRIVATE).join(git::CHECKOUT_CONFIG)).ok();
if made.is_none() || made != std::fs::read(dir.join(".git/config")).ok() {
found.push(".git/config".to_owned());
}
let hooks = dir.join(".git/hooks");
let mut folders = vec![hooks.clone()];
while let Some(folder) = folders.pop() {
for entry in std::fs::read_dir(&folder).into_iter().flatten().flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
folders.push(path);
} else if let Ok(rel) = path.strip_prefix(&hooks) {
found.push(format!(".git/hooks/{}", rel.to_string_lossy()));
}
}
}
found.sort();
found
}
pub fn remove(home: &Home, name: &str, dir: &Path) -> Result<()> {
if find(home, dir).as_deref() == Some(name) {
store::remove_copy(dir)?;
}
Ok(())
}