use std::path::{Path, PathBuf};
use crate::commands::status;
use crate::commands::PackStatusResult;
use crate::conflicts;
use crate::fs::Fs;
use crate::packs;
use crate::packs::orchestration::{self, ExecutionContext};
use crate::rules;
use crate::{DodotError, Result};
struct AdoptPlan {
source: PathBuf,
pack_dest: PathBuf,
is_dir: bool,
destructive_overwrite: bool,
}
pub fn adopt(
pack_name: &str,
sources: &[PathBuf],
force: bool,
no_follow: bool,
dry_run: bool,
ctx: &ExecutionContext,
) -> Result<PackStatusResult> {
if sources.is_empty() {
return Err(DodotError::Other("no files specified".into()));
}
let pack_path = ctx.paths.pack_path(pack_name);
if !ctx.fs.exists(&pack_path) {
return Err(DodotError::PackNotFound {
name: pack_name.into(),
});
}
if ctx.fs.exists(&pack_path.join(".dodotignore")) {
return Err(DodotError::PackInvalid {
name: pack_name.into(),
reason: "pack is marked ignored via .dodotignore".into(),
});
}
let (plans, skipped_already_adopted) =
preflight(pack_name, &pack_path, sources, force, no_follow, ctx)?;
if plans.is_empty() {
let mut result = status::status(Some(&[pack_name.to_string()]), ctx)?;
result.dry_run = dry_run;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
return Ok(result);
}
if let Err(e) = copy_all(&plans, ctx.fs.as_ref()) {
cleanup_pack_copies(&plans, ctx.fs.as_ref());
return Err(e);
}
if let Err(e) = check_deploy_conflicts(ctx) {
cleanup_pack_copies(&plans, ctx.fs.as_ref());
return Err(e);
}
if dry_run {
cleanup_pack_copies(&plans, ctx.fs.as_ref());
let mut result = status::status(Some(&[pack_name.to_string()]), ctx)?;
result.dry_run = true;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
return Ok(result);
}
let failures = swap_all(&plans, ctx.fs.as_ref());
let mut result = status::status(Some(&[pack_name.to_string()]), ctx)?;
result.dry_run = false;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
for f in &failures {
result.warnings.push(format!(
"adopt failed: {}: {}",
f.source.display(),
f.reason
));
}
Ok(result)
}
fn preflight(
pack_name: &str,
pack_path: &Path,
sources: &[PathBuf],
force: bool,
no_follow: bool,
ctx: &ExecutionContext,
) -> Result<(Vec<AdoptPlan>, Vec<String>)> {
let fs = ctx.fs.as_ref();
let home = ctx.paths.home_dir().to_path_buf();
let dotfiles_root = ctx.paths.dotfiles_root().to_path_buf();
let data_dir = ctx.paths.data_dir().to_path_buf();
let root_config = ctx.config_manager.root_config()?;
let pack_config = ctx.config_manager.config_for_pack(pack_path)?;
let ignore_patterns = {
let mut combined = root_config.pack.ignore.clone();
combined.extend(pack_config.pack.ignore.iter().cloned());
combined
};
let mut plans: Vec<AdoptPlan> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for raw_source in sources {
let abs = if raw_source.is_absolute() {
raw_source.clone()
} else {
std::env::current_dir()
.map_err(|e| DodotError::Fs {
path: raw_source.clone(),
source: e,
})?
.join(raw_source)
};
let abs = normalize_path(&abs);
if !fs.exists(&abs) && !fs.is_symlink(&abs) {
return Err(DodotError::Fs {
path: abs,
source: std::io::Error::new(std::io::ErrorKind::NotFound, "source does not exist"),
});
}
if fs.is_symlink(&abs) {
if let Ok(target) = fs.readlink(&abs) {
if target.starts_with(&dotfiles_root) || target.starts_with(&data_dir) {
skipped.push(format!(
"skipped: {} is already managed by dodot (-> {})",
abs.display(),
target.display()
));
continue;
}
}
}
let lmeta = fs.lstat(&abs)?;
let is_source_symlink = lmeta.is_symlink;
let treat_as_link = is_source_symlink && no_follow;
let is_dir = if treat_as_link {
false
} else {
let smeta = fs.stat(&abs)?;
smeta.is_dir
};
let parent = abs
.parent()
.ok_or_else(|| DodotError::Other(format!("no parent directory: {}", abs.display())))?;
let canon_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
let canon_home = std::fs::canonicalize(&home).unwrap_or_else(|_| home.clone());
if canon_parent != canon_home {
return Err(DodotError::Other(format!(
"nested source not allowed: {}\n hint: adopt the top-level directory instead (parent must be {})",
abs.display(),
home.display()
)));
}
let file_name = abs
.file_name()
.ok_or_else(|| DodotError::Other(format!("no filename: {}", abs.display())))?
.to_string_lossy()
.into_owned();
let pack_filename = file_name
.strip_prefix('.')
.unwrap_or(&file_name)
.to_string();
if rules::should_skip_entry(&pack_filename, &ignore_patterns) {
return Err(DodotError::Other(format!(
"refusing to adopt {}: name '{}' matches an ignore pattern or is reserved",
abs.display(),
pack_filename
)));
}
let pack_dest = pack_path.join(&pack_filename);
let dest_exists = fs.exists(&pack_dest) || fs.is_symlink(&pack_dest);
if dest_exists && !force {
return Err(DodotError::SymlinkConflict { path: pack_dest });
}
if plans.iter().any(|p| p.pack_dest == pack_dest) {
return Err(DodotError::Other(format!(
"two sources produce the same pack filename '{}'; adopt them separately",
pack_filename
)));
}
plans.push(AdoptPlan {
source: abs,
pack_dest,
is_dir,
destructive_overwrite: dest_exists, });
}
let _ = pack_name; check_writable(fs, pack_path)?;
for plan in &plans {
check_readable(fs, &plan.source, plan.is_dir)?;
if let Some(src_parent) = plan.source.parent() {
check_writable(fs, src_parent)?;
}
}
Ok((plans, skipped))
}
fn check_writable(fs: &dyn Fs, dir: &Path) -> Result<()> {
let probe = dir.join(format!(".dodot-adopt-probe-{}", nonce()));
fs.write_file(&probe, b"").map_err(|e| {
DodotError::Other(format!("not writable: {}: {}", dir.display(), err_msg(&e)))
})?;
let _ = fs.remove_file(&probe);
Ok(())
}
fn check_readable(fs: &dyn Fs, path: &Path, is_dir: bool) -> Result<()> {
if is_dir {
fs.read_dir(path).map(|_| ())
} else {
fs.lstat(path).map(|_| ())
}
}
fn copy_all(plans: &[AdoptPlan], fs: &dyn Fs) -> Result<()> {
for plan in plans {
let had_existing_dest = fs.exists(&plan.pack_dest) || fs.is_symlink(&plan.pack_dest);
if had_existing_dest {
let stage = temp_sibling(&plan.pack_dest, "stage");
if let Err(e) = copy_tree(&plan.source, &stage, fs) {
remove_best_effort(fs, &stage);
return Err(e);
}
remove_path(&plan.pack_dest, fs)?;
if let Err(e) = fs.rename(&stage, &plan.pack_dest) {
remove_best_effort(fs, &stage);
return Err(e);
}
} else {
copy_tree(&plan.source, &plan.pack_dest, fs)?;
}
}
Ok(())
}
fn remove_path(path: &Path, fs: &dyn Fs) -> Result<()> {
if fs.is_symlink(path) {
fs.remove_file(path)
} else if fs.is_dir(path) {
fs.remove_dir_all(path)
} else {
fs.remove_file(path)
}
}
fn copy_tree(src: &Path, dst: &Path, fs: &dyn Fs) -> Result<()> {
let meta = fs.lstat(src)?;
if meta.is_symlink {
let target = fs.readlink(src)?;
fs.symlink(&target, dst)?;
return Ok(());
}
if meta.is_dir {
fs.mkdir_all(dst)?;
let _ = fs.set_permissions(dst, meta.mode);
for entry in fs.read_dir(src)? {
copy_tree(&entry.path, &dst.join(&entry.name), fs)?;
}
return Ok(());
}
if meta.is_file {
fs.copy_file(src, dst)?;
let _ = fs.set_permissions(dst, meta.mode);
return Ok(());
}
Err(DodotError::Other(format!(
"unsupported file type: {}",
src.display()
)))
}
fn cleanup_pack_copies(plans: &[AdoptPlan], fs: &dyn Fs) {
for plan in plans {
if plan.destructive_overwrite {
continue;
}
remove_best_effort(fs, &plan.pack_dest);
}
}
fn remove_best_effort(fs: &dyn Fs, path: &Path) {
if fs.is_symlink(path) {
let _ = fs.remove_file(path);
} else if fs.is_dir(path) {
let _ = fs.remove_dir_all(path);
} else if fs.exists(path) {
let _ = fs.remove_file(path);
}
}
fn check_deploy_conflicts(ctx: &ExecutionContext) -> Result<()> {
let root_config = ctx.config_manager.root_config()?;
let packs::DiscoveredPacks { packs: all, .. } = packs::scan_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
let mut pack_intents = Vec::new();
for mut pack in all {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
pack.config = pack_config.to_handler_config();
let intents = orchestration::collect_pack_intents(&pack, ctx)?;
pack_intents.push((pack.name.clone(), intents));
}
let conflicts = conflicts::detect_cross_pack_conflicts(&pack_intents, ctx.fs.as_ref());
if !conflicts.is_empty() {
return Err(DodotError::CrossPackConflict { conflicts });
}
Ok(())
}
struct AdoptFailure {
source: PathBuf,
reason: String,
}
fn swap_all(plans: &[AdoptPlan], fs: &dyn Fs) -> Vec<AdoptFailure> {
let mut failures = Vec::new();
for plan in plans {
let result = if plan.is_dir {
swap_dir(&plan.source, &plan.pack_dest, fs)
} else {
swap_file_atomic(&plan.source, &plan.pack_dest, fs)
};
if let Err(e) = result {
remove_best_effort(fs, &plan.pack_dest);
failures.push(AdoptFailure {
source: plan.source.clone(),
reason: format!("{}", e),
});
}
}
failures
}
fn swap_file_atomic(source: &Path, pack_dest: &Path, fs: &dyn Fs) -> Result<()> {
let tmp = temp_sibling(source, "tmp");
fs.symlink(pack_dest, &tmp)?;
if let Err(e) = fs.rename(&tmp, source) {
let _ = fs.remove_file(&tmp);
return Err(e);
}
Ok(())
}
fn swap_dir(source: &Path, pack_dest: &Path, fs: &dyn Fs) -> Result<()> {
let backup = temp_sibling(source, "old");
fs.rename(source, &backup)?;
match fs.symlink(pack_dest, source) {
Ok(()) => {
let _ = fs.remove_dir_all(&backup);
Ok(())
}
Err(e) => {
let _ = fs.rename(&backup, source);
Err(e)
}
}
}
fn normalize_path(path: &Path) -> PathBuf {
use std::path::Component;
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
result.pop();
}
other => result.push(other),
}
}
result
}
fn temp_sibling(path: &Path, tag: &str) -> PathBuf {
let parent = path.parent().unwrap_or(Path::new("."));
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
parent.join(format!(".dodot-adopt-{}-{}-{}", tag, name, nonce()))
}
fn nonce() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let n = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{:x}", n)
}
fn err_msg(e: &DodotError) -> String {
format!("{e}")
}