mod classify;
mod infer;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use crate::commands::status;
use crate::commands::{DisplayFile, DisplayNote, DisplayPack, PackStatusResult};
use crate::conflicts;
use crate::fs::{FileId, Fs};
use crate::packs;
use crate::packs::orchestration::{self, ExecutionContext};
use crate::{DodotError, Result};
use self::classify::{classify, pack_dir_refusal, EffectiveIgnore, SkipRule};
use self::infer::{infer_target, InferredTarget};
#[cfg(test)]
pub(crate) use self::infer::derive_home_in_pack as derive_pack_filename;
struct AdoptPlan {
source: PathBuf,
in_pack: PathBuf,
pack_dest: PathBuf,
is_dir: bool,
destructive_overwrite: bool,
}
struct LeftInPlace {
path: PathBuf,
rule: SkipRule,
}
struct PlannedRun {
plans: Vec<AdoptPlan>,
skipped_already_adopted: Vec<String>,
left_in_place: Vec<LeftInPlace>,
}
pub fn adopt(
pack_override: Option<&str>,
sources: &[PathBuf],
force: bool,
no_follow: bool,
dry_run: bool,
only_os: Option<&str>,
ctx: &ExecutionContext,
) -> Result<PackStatusResult> {
if let Some(label) = only_os {
let root_config = ctx.config_manager.root_config()?;
let mut gates = crate::gates::GateTable::with_builtins();
if !root_config.gates.is_empty() {
gates.merge_user(&root_config.gates)?;
}
if !gates.contains(label) {
return Err(DodotError::Config(format!(
"unknown gate label `{label}` for --only-os: \
not in the built-in seed and not defined in [gates]. \
Built-ins: darwin, linux, macos, arm64, aarch64, x86_64."
)));
}
}
if sources.is_empty() {
return Err(DodotError::Other("no files specified".into()));
}
let resolved = resolve_pack_for_sources(pack_override, sources, ctx)?;
let pack_dir = resolved.pack_dir.clone();
let pack_display = resolved.display_name.clone();
let pack_path = ctx.paths.pack_path(&pack_dir);
let pack_existed = ctx.fs.exists(&pack_path);
let root_ignore = EffectiveIgnore::root(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
ctx.config_manager.root_config()?.pack.ignore.clone(),
);
if let Some(message) = pack_dir_refusal(&pack_dir, &root_ignore, ctx.paths.dotfiles_root()) {
return Err(DodotError::Other(message));
}
if ctx.fs.exists(&pack_path.join(".dodotignore")) {
return Err(DodotError::PackInvalid {
name: pack_display.clone(),
reason: "pack is marked ignored via .dodotignore".into(),
});
}
let PlannedRun {
plans,
skipped_already_adopted,
left_in_place,
} = plan(
&pack_display,
&pack_path,
pack_existed,
sources,
pack_override,
force,
no_follow,
only_os,
ctx,
)?;
if plans.is_empty() {
let mut result = adopt_result(&pack_display, &pack_path, &plans, ctx)?;
result.dry_run = dry_run;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
report_left_in_place(&mut result, &left_in_place, &pack_display);
return Ok(result);
}
let prep = Preparation::create(ctx.fs.as_ref(), ctx.paths.dotfiles_root(), &pack_dir)?;
if let Err(e) = prep.fill(&plans, ctx.fs.as_ref()) {
prep.discard(ctx.fs.as_ref());
return Err(e);
}
let superseded: Vec<PathBuf> = if pack_existed {
plans.iter().map(|p| p.in_pack.clone()).collect()
} else {
Vec::new()
};
if let Err(e) = check_deploy_conflicts(
ctx,
ProspectiveTree {
pack_dir: &pack_dir,
prepared_root: prep.pack_root(),
config_at: &pack_path,
superseded: &superseded,
},
) {
prep.discard(ctx.fs.as_ref());
return Err(e);
}
if dry_run {
prep.discard(ctx.fs.as_ref());
let mut result = adopt_result(&pack_display, &pack_path, &plans, ctx)?;
result.dry_run = true;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
report_left_in_place(&mut result, &left_in_place, &pack_display);
return Ok(result);
}
let published = if pack_existed {
prep.publish_into_existing(&pack_path, &pack_display, &plans, ctx.fs.as_ref())
} else {
match prep.publish_new_pack(&pack_path, &plans, ctx.fs.as_ref()) {
Ok(published) => published,
Err(error) => Published::Failed {
error,
keep_preparation: false,
},
}
};
let publication = match published {
Published::Ok(publication) => publication,
Published::Failed {
error,
keep_preparation,
} => {
if !keep_preparation {
prep.discard(ctx.fs.as_ref());
}
return Err(error);
}
};
let failures = swap_all(&plans, &publication, &pack_path, ctx.fs.as_ref());
if failures.iter().all(|f| f.stranded.is_none()) {
prep.discard(ctx.fs.as_ref());
}
let mut result = if ctx.fs.exists(&pack_path) {
status::status(Some(std::slice::from_ref(&pack_display)), ctx)?
} else {
bare_result(&pack_display, Vec::new(), ctx)
};
result.dry_run = false;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
report_left_in_place(&mut result, &left_in_place, &pack_display);
let force_home = ctx.config_manager.root_config()?.symlink.force_home.clone();
let any_app_support = sources.iter().any(|s| {
absolutize(s)
.ok()
.and_then(|abs| {
let is_dir = ctx.fs.stat(&abs).map(|m| m.is_dir).unwrap_or(false);
infer::infer_target(&abs, is_dir, ctx.paths.as_ref(), &force_home).ok()
})
.map(|t| t.source_root == infer::SourceRoot::AppSupport)
.unwrap_or(false)
});
let cache_dir = ctx.paths.probes_brew_cache_dir();
let now = crate::probe::brew::now_secs_unix();
let cask_matches = if any_app_support {
crate::probe::brew::match_folders_to_installed_casks(
std::slice::from_ref(&pack_display),
ctx.command_runner.as_ref(),
&cache_dir,
now,
ctx.fs.as_ref(),
false,
)
} else {
crate::probe::brew::InstalledCaskMatches::default()
};
let cask_token: Option<&str> = cask_matches
.folder_to_token
.get(&pack_display)
.map(String::as_str);
if pack_override.is_none() && infer::is_gui_app_folder(&pack_display) && any_app_support {
let lowercase_fallback: String = pack_display
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(char::to_lowercase)
.collect();
let suggested_alias = cask_token.unwrap_or(lowercase_fallback.as_str());
if !suggested_alias.is_empty() && suggested_alias != pack_display {
let cask_credit = match cask_token {
Some(token) => format!(" (matches homebrew cask `{token}`)"),
None => String::new(),
};
result.warnings.push(format!(
"tip: pack `{pack_display}` looks like a macOS GUI-app folder{cask_credit}. \
Consider renaming the pack to `{suggested_alias}` and adding\n \
[symlink.app_aliases]\n {suggested_alias} = \"{pack_display}\"\n\
to your .dodot.toml so future files can use bare paths instead \
of `_app/{pack_display}/...`."
));
}
}
if let Some(token) = cask_token {
result.warnings.push(format!(
"homebrew cask `{token}` confirms this is the app-support directory \
for pack `{pack_display}`."
));
if let Ok(Some(info)) = crate::probe::brew::info_cask(
token,
&cache_dir,
now,
ctx.fs.as_ref(),
ctx.command_runner.as_ref(),
) {
let plists = info.preferences_plists();
let candidates: Vec<&str> = plists
.iter()
.filter_map(|p| {
let leaf = p.split('/').next_back()?;
if leaf.is_empty() {
None
} else {
Some(leaf)
}
})
.collect();
if !candidates.is_empty() {
let list = candidates.join(", ");
result.warnings.push(format!(
"homebrew also reports preferences for cask `{token}`: {list}. \
Adopt them too with `dodot adopt ~/Library/Preferences/<file> --into {pack_display}`."
));
}
}
}
let adopted_any_plist = plans.iter().any(|p| {
p.source
.extension()
.and_then(|e| e.to_str())
.map(|s| s.eq_ignore_ascii_case("plist"))
.unwrap_or(false)
});
if adopted_any_plist && !crate::commands::git_filters::is_installed(ctx).unwrap_or(true) {
result.warnings.push(
"tip: pack now contains a .plist file. Run `dodot git-install-filters` to enable \
canonical XML diffs (binary plists become diffable in `git status`/`git diff`)."
.into(),
);
}
for f in &failures {
let src_name = f
.source
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| f.source.display().to_string());
let outcome = match f.stranded {
None => "its pack entry was taken back out",
Some(_) => "putting the pack back the way it was failed too",
};
result.notes.push(DisplayNote::error(format!(
"adopt failed: {}: {} — {outcome}",
f.source.display(),
f.reason
)));
let note_ref = Some(result.notes.len() as u32);
if let Some(entry) = &f.stranded {
result.notes.push(DisplayNote::error(format!(
"adopt could not put back the pack's pre-adopt state for {}: \
the content it could not move is at {}. Nothing was deleted \
to get past that, and the staging directory {} is kept rather \
than discarded — move what you need back by hand, then remove \
that directory.",
entry.in_pack,
entry.at,
prep.root.display()
)));
}
let pack = match result.packs.iter_mut().position(|p| p.name == pack_display) {
Some(index) => &mut result.packs[index],
None => {
result
.packs
.push(DisplayPack::new(pack_display.clone(), Vec::new()));
result.packs.last_mut().expect("just pushed")
}
};
pack.files.push(DisplayFile {
name: src_name,
symbol: "×".into(),
description: "adopt failed".into(),
status: "error".into(),
status_label: "error".into(),
handler: String::new(),
note_ref,
});
pack.recompute_summary();
}
result.failed = !failures.is_empty();
Ok(result)
}
const PREPARATION_PREFIX: &str = ".dodot-adopt-";
const PREPARATION_NAME_ATTEMPTS: u32 = 8;
const DISPLACED_DIR: &str = ".displaced";
struct Preparation {
root: PathBuf,
pack_root: PathBuf,
}
impl Preparation {
fn create(fs: &dyn Fs, dotfiles_root: &Path, pack_dir: &str) -> Result<Self> {
let mut attempt = 0;
loop {
let root = dotfiles_root.join(format!("{PREPARATION_PREFIX}{}", nonce()));
match fs.mkdir_exclusive(&root) {
Ok(()) => {
let pack_root = root.join(pack_dir);
if let Err(e) = fs.mkdir_all(&pack_root) {
remove_best_effort(fs, &root);
return Err(e);
}
return Ok(Preparation { root, pack_root });
}
Err(e) => {
attempt += 1;
if !crate::fs::is_already_exists(&e) || attempt >= PREPARATION_NAME_ATTEMPTS {
return Err(e);
}
}
}
}
}
fn pack_root(&self) -> &Path {
&self.pack_root
}
fn fill(&self, plans: &[AdoptPlan], fs: &dyn Fs) -> Result<()> {
for plan in plans {
let dest = self.pack_root.join(&plan.in_pack);
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() && !fs.exists(parent) {
fs.mkdir_all(parent)?;
}
}
copy_tree(&plan.source, &dest, fs)?;
}
Ok(())
}
fn publish_new_pack(
&self,
pack_path: &Path,
plans: &[AdoptPlan],
fs: &dyn Fs,
) -> Result<Published> {
let prepared_ids: Vec<Option<PreparedId>> = plans
.iter()
.map(|plan| prepared_identity(fs, &self.pack_root.join(&plan.in_pack)))
.collect();
fs.rename_noreplace(&self.pack_root, pack_path)
.map_err(|e| {
if crate::fs::is_already_exists(&e) {
DodotError::Other(format!(
"pack path {} appeared while adopt was preparing; refusing to \
merge into it. Re-run adopt to plan against the pack that now \
exists.",
pack_path.display()
))
} else {
e
}
})
.map(|()| {
let published = plans
.iter()
.zip(prepared_ids)
.filter_map(|(plan, prepared)| {
let id = published_identity(fs, prepared, &plan.pack_dest)?;
Some((plan.in_pack.clone(), id))
})
.collect();
Published::Ok(Publication::NewPack { published })
})
}
fn publish_into_existing(
&self,
pack_path: &Path,
pack_display: &str,
plans: &[AdoptPlan],
fs: &dyn Fs,
) -> Published {
let mut record = PublicationRecord::default();
for plan in plans {
if let Err(e) = self.publish_one(pack_path, plan, fs, &mut record) {
let outcome = record.undo(fs);
let reason = err_msg(&e);
if outcome.stranded.is_empty() {
return Published::Failed {
error: DodotError::PublicationRolledBack {
pack: pack_display.to_string(),
reason,
restored: outcome.restored,
},
keep_preparation: false,
};
}
return Published::Failed {
error: DodotError::PublicationRollbackIncomplete {
pack: pack_display.to_string(),
reason,
restored: outcome.restored,
stranded: outcome.stranded,
preparation: self.root.display().to_string(),
},
keep_preparation: true,
};
}
}
Published::Ok(Publication::Existing(record))
}
fn publish_one(
&self,
pack_path: &Path,
plan: &AdoptPlan,
fs: &dyn Fs,
record: &mut PublicationRecord,
) -> Result<()> {
create_intermediates(fs, pack_path, &plan.in_pack, &mut record.created_dirs)?;
let mut entry = PublishedEntry {
in_pack: plan.in_pack.clone(),
final_path: plan.pack_dest.clone(),
prepared: self.pack_root.join(&plan.in_pack),
displaced: None,
published: false,
identity: None,
};
if plan.destructive_overwrite
&& (fs.exists(&plan.pack_dest) || fs.is_symlink(&plan.pack_dest))
{
let displaced = self.displaced_root().join(&plan.in_pack);
if let Some(parent) = displaced.parent() {
fs.mkdir_all(parent)?;
}
fs.rename(&plan.pack_dest, &displaced)?;
entry.displaced = Some(displaced);
}
let prepared_id = prepared_identity(fs, &entry.prepared);
let result = fs.rename_noreplace(&entry.prepared, &plan.pack_dest);
entry.published = result.is_ok();
if entry.published {
entry.identity = published_identity(fs, prepared_id, &plan.pack_dest);
}
record.entries.push(entry);
result
}
fn displaced_root(&self) -> PathBuf {
self.root.join(DISPLACED_DIR)
}
fn discard(&self, fs: &dyn Fs) {
remove_best_effort(fs, &self.root);
}
}
#[derive(Default)]
struct PublicationRecord {
created_dirs: Vec<PathBuf>,
entries: Vec<PublishedEntry>,
}
struct PublishedEntry {
in_pack: PathBuf,
final_path: PathBuf,
prepared: PathBuf,
displaced: Option<PathBuf>,
published: bool,
identity: Option<PublishedId>,
}
struct PublishedId {
entry: FileId,
descendants: Vec<(PathBuf, FileId)>,
}
struct PreparedId {
entry: FileId,
descendants: Vec<(PathBuf, FileId)>,
}
#[derive(Default)]
struct UndoOutcome {
restored: Vec<String>,
stranded: Vec<StrandedEntry>,
}
#[derive(Debug)]
pub struct StrandedEntry {
pub in_pack: String,
pub at: String,
}
impl PublicationRecord {
fn undo(&self, fs: &dyn Fs) -> UndoOutcome {
let mut outcome = UndoOutcome::default();
for entry in self.entries.iter().rev() {
let in_pack = entry.in_pack.display().to_string();
let final_path_free = !entry.published || vacate(fs, entry);
match &entry.displaced {
Some(displaced) => {
if final_path_free && restore_displaced(fs, displaced, &entry.final_path) {
outcome.restored.push(in_pack);
} else {
outcome.stranded.push(StrandedEntry {
in_pack,
at: displaced.display().to_string(),
});
}
}
None if entry.published => {
if final_path_free {
outcome.restored.push(in_pack);
} else {
outcome.stranded.push(StrandedEntry {
in_pack,
at: entry.final_path.display().to_string(),
});
}
}
None => {}
}
}
outcome.restored.reverse();
outcome.stranded.reverse();
for dir in self.created_dirs.iter().rev() {
let _ = fs.remove_dir_empty(dir);
}
outcome
}
}
enum Published {
Ok(Publication),
Failed {
error: DodotError,
keep_preparation: bool,
},
}
enum Publication {
NewPack {
published: HashMap<PathBuf, PublishedId>,
},
Existing(PublicationRecord),
}
fn create_intermediates(
fs: &dyn Fs,
pack_path: &Path,
in_pack: &Path,
created: &mut Vec<PathBuf>,
) -> Result<()> {
let Some(parent) = in_pack.parent() else {
return Ok(());
};
let mut dir = pack_path.to_path_buf();
for component in parent.components() {
dir = dir.join(component);
match fs.mkdir_exclusive(&dir) {
Ok(()) => created.push(dir.clone()),
Err(e) if crate::fs::is_already_exists(&e) => {
if !fs.is_dir(&dir) {
return Err(DodotError::Other(format!(
"{} is not a directory, and adopting {} needs it to be one",
dir.display(),
in_pack.display()
)));
}
}
Err(e) => return Err(e),
}
}
Ok(())
}
fn adopt_result(
pack_display: &str,
pack_path: &Path,
plans: &[AdoptPlan],
ctx: &ExecutionContext,
) -> Result<PackStatusResult> {
if ctx.fs.exists(pack_path) {
return status::status(Some(&[pack_display.to_string()]), ctx);
}
let files: Vec<DisplayFile> = plans
.iter()
.map(|p| DisplayFile {
name: p.in_pack.display().to_string(),
symbol: "+".into(),
description: format!("would adopt {}", p.source.display()),
status: "pending".into(),
status_label: "planned".into(),
handler: String::new(),
note_ref: None,
})
.collect();
Ok(bare_result(pack_display, files, ctx))
}
fn bare_result(
pack_display: &str,
files: Vec<DisplayFile>,
ctx: &ExecutionContext,
) -> PackStatusResult {
PackStatusResult {
message: None,
dry_run: false,
packs: vec![DisplayPack::new(pack_display.to_string(), files)],
warnings: Vec::new(),
notes: Vec::new(),
conflicts: Vec::new(),
ignored_packs: Vec::new(),
inactive_packs: Vec::new(),
view_mode: ctx.view_mode.as_str().into(),
group_mode: ctx.group_mode.as_str().into(),
diffs: Vec::new(),
shell_hookup: status::shell_hookup_notice(ctx),
failed: false,
}
}
struct ResolvedPack {
pack_dir: String,
display_name: String,
}
fn resolve_pack_for_sources(
pack_override: Option<&str>,
sources: &[PathBuf],
ctx: &ExecutionContext,
) -> Result<ResolvedPack> {
if let Some(name) = pack_override {
let pack_dir = orchestration::resolve_pack_dir_name(name, ctx)?;
let display_name = packs::display_name_for(&pack_dir).to_string();
return Ok(ResolvedPack {
pack_dir,
display_name,
});
}
let force_home = ctx.config_manager.root_config()?.symlink.force_home.clone();
let fs = ctx.fs.as_ref();
let mut candidates: BTreeSet<String> = BTreeSet::new();
let mut declined: Vec<PathBuf> = Vec::new();
for raw in sources {
let abs = absolutize(raw)?;
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"),
});
}
let is_dir = fs.stat(&abs).map(|m| m.is_dir).unwrap_or(false);
match infer_target(&abs, is_dir, ctx.paths.as_ref(), &force_home) {
Ok(t) => match t.natural_pack {
Some(name) => {
candidates.insert(name);
}
None => declined.push(abs),
},
Err(e) => {
return Err(DodotError::Other(format!(
"refusing to adopt {}: {e}",
abs.display()
)))
}
}
}
match candidates.len() {
0 => Err(DodotError::Other(format!(
"could not infer a pack name for {} source(s); pass --into <pack>",
declined.len()
))),
1 => {
let inferred = candidates.into_iter().next().unwrap();
let pack_dir = orchestration::resolve_pack_dir_name(&inferred, ctx)
.unwrap_or_else(|_| inferred.clone());
let display_name = packs::display_name_for(&pack_dir).to_string();
let _ = declined;
Ok(ResolvedPack {
pack_dir,
display_name,
})
}
_ => {
let names: Vec<String> = candidates.into_iter().collect();
Err(DodotError::Other(format!(
"sources infer different packs ({}); split into separate adopt \
invocations or pass --into <pack> to force a single destination",
names.join(", ")
)))
}
}
}
#[allow(clippy::too_many_arguments)]
fn plan(
pack_display: &str,
pack_path: &Path,
pack_exists: bool,
sources: &[PathBuf],
pack_override: Option<&str>,
force: bool,
no_follow: bool,
only_os: Option<&str>,
ctx: &ExecutionContext,
) -> Result<PlannedRun> {
let fs = ctx.fs.as_ref();
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 = EffectiveIgnore::resolve(
fs,
&dotfiles_root,
pack_path,
pack_config.pack.ignore.clone(),
);
let gates = {
let mut table = crate::gates::GateTable::with_builtins();
if !pack_config.gates.is_empty() {
table.merge_user(&pack_config.gates)?;
}
table
};
let host = ctx.host_facts.as_ref();
let force_home = {
let mut combined = root_config.symlink.force_home.clone();
combined.extend(pack_config.symlink.force_home.iter().cloned());
combined
};
let mut plans: Vec<AdoptPlan> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
let mut left_in_place: Vec<LeftInPlace> = Vec::new();
for raw_source in sources {
let abs = absolutize(raw_source)?;
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(raw_target) = fs.readlink(&abs) {
let resolved = crate::equivalence::resolve_symlink_target(&abs, &raw_target);
if resolved.starts_with(&data_dir) {
skipped.push(format!(
"skipped: {} is already managed by dodot (-> {})",
abs.display(),
raw_target.display()
));
continue;
}
if resolved.starts_with(&dotfiles_root) {
skipped.push(format!(
"skipped: {} is a direct symlink to pack source (-> {}); \
run `dodot up {}` to upgrade it to dodot's full chain",
abs.display(),
raw_target.display(),
pack_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 inferred =
infer_target(&abs, is_dir, ctx.paths.as_ref(), &force_home).map_err(|reason| {
DodotError::Other(format!("refusing to adopt {}: {reason}", abs.display()))
})?;
let in_pack = match (&inferred.natural_pack, pack_override) {
(Some(natural), Some(over)) if natural != over => inferred.in_pack_override.clone(),
_ => inferred.in_pack_natural.clone(),
};
let in_pack = if let Some(label) = only_os {
std::path::PathBuf::from(format!("_{label}")).join(&in_pack)
} else {
in_pack
};
if inferred.expand_children {
let override_differs = matches!(
(&inferred.natural_pack, pack_override),
(Some(natural), Some(over)) if natural != over
);
let entries = fs.read_dir(&abs)?;
let mut unadoptable: Vec<(String, SkipRule)> = Vec::new();
let mut adopted_a_child = false;
for entry in entries {
let child_in_pack = expand_child_in_pack(&inferred, &entry.name, override_differs);
let child_in_pack = if let Some(label) = only_os {
std::path::PathBuf::from(format!("_{label}")).join(&child_in_pack)
} else {
child_in_pack
};
let child_source = abs.join(&entry.name);
match classify(&child_in_pack, entry.is_dir, &ignore, &gates, host) {
Some(rule @ SkipRule::Reserved { .. }) => {
return Err(DodotError::Other(rule.refusal(
&child_source,
&child_in_pack,
pack_display,
)));
}
Some(rule) => {
unadoptable.push((entry.name.clone(), rule.clone()));
left_in_place.push(LeftInPlace {
path: child_source,
rule,
});
}
None => {
adopted_a_child = true;
push_plan(
&mut plans,
fs,
&child_source,
pack_path,
&child_in_pack,
no_follow,
force,
)?;
}
}
}
if !adopted_a_child {
return Err(DodotError::Other(no_adoptable_children(
&abs,
&unadoptable,
pack_display,
)));
}
} else {
if let Some(rule) = classify(&in_pack, is_dir, &ignore, &gates, host) {
return Err(DodotError::Other(rule.refusal(
&abs,
&in_pack,
pack_display,
)));
}
push_plan(&mut plans, fs, &abs, pack_path, &in_pack, no_follow, force)?;
}
}
check_overlaps(&plans)?;
check_writable(fs, &dotfiles_root)?;
if pack_exists {
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(PlannedRun {
plans,
skipped_already_adopted: skipped,
left_in_place,
})
}
fn no_adoptable_children(dir: &Path, children: &[(String, SkipRule)], pack: &str) -> String {
if children.is_empty() {
return format!(
"refusing to adopt {}: expanding this directory found no adoptable \
entries — it has no children.",
dir.display()
);
}
let headline = if children.len() == 1 {
"its only child is skipped by a discovery rule:".to_string()
} else {
format!(
"all {} children are skipped by a discovery rule:",
children.len()
)
};
let width = children
.iter()
.map(|(name, _)| name.chars().count())
.max()
.unwrap_or(0);
let mut message = format!(
"refusing to adopt {}: expanding this directory found no adoptable \
entries, {headline}",
dir.display()
);
for (name, rule) in children {
message.push_str(&format!(
"\n {name:<width$} {}",
rule.short(pack),
width = width
));
}
message
}
fn report_left_in_place(result: &mut PackStatusResult, left: &[LeftInPlace], pack: &str) {
for entry in left {
result.warnings.push(format!(
"left in place: {} — {}",
entry.path.display(),
entry.rule.reported(pack)
));
}
}
fn check_overlaps(plans: &[AdoptPlan]) -> Result<()> {
for (i, a) in plans.iter().enumerate() {
for b in &plans[i + 1..] {
if let Some((outer, inner)) = source_containment(a, b) {
return Err(DodotError::Other(format!(
"{} contains {}; adopt the outer one alone — adopting a \
directory already carries its contents",
outer.source.display(),
inner.source.display()
)));
}
if let Some((outer, inner)) = in_pack_containment(a, b) {
return Err(DodotError::Other(format!(
"{} and {} would land at {} and {} in the pack, one inside \
the other; adopt them in one run only if their pack paths \
don't nest, or adopt them separately",
outer.source.display(),
inner.source.display(),
outer.in_pack.display(),
inner.in_pack.display()
)));
}
}
}
Ok(())
}
fn source_containment<'a>(
a: &'a AdoptPlan,
b: &'a AdoptPlan,
) -> Option<(&'a AdoptPlan, &'a AdoptPlan)> {
if b.source.starts_with(&a.source) {
Some((a, b))
} else if a.source.starts_with(&b.source) {
Some((b, a))
} else {
None
}
}
fn in_pack_containment<'a>(
a: &'a AdoptPlan,
b: &'a AdoptPlan,
) -> Option<(&'a AdoptPlan, &'a AdoptPlan)> {
if b.in_pack.starts_with(&a.in_pack) {
Some((a, b))
} else if a.in_pack.starts_with(&b.in_pack) {
Some((b, a))
} else {
None
}
}
fn expand_child_in_pack(
parent: &InferredTarget,
child_name: &str,
override_differs: bool,
) -> PathBuf {
use self::infer::SourceRoot;
match parent.source_root {
SourceRoot::XdgConfig => {
if override_differs {
parent.in_pack_override.join(child_name)
} else {
PathBuf::from(child_name)
}
}
SourceRoot::AppSupport => {
parent.in_pack_override.join(child_name)
}
SourceRoot::Home => {
PathBuf::from(child_name)
}
SourceRoot::Library => {
parent.in_pack_override.join(child_name)
}
}
}
#[allow(clippy::too_many_arguments)]
fn push_plan(
plans: &mut Vec<AdoptPlan>,
fs: &dyn Fs,
source: &Path,
pack_path: &Path,
in_pack: &Path,
no_follow: bool,
force: bool,
) -> Result<()> {
let lmeta = fs.lstat(source)?;
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 {
fs.stat(source)?.is_dir
};
let pack_dest = pack_path.join(in_pack);
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 path '{}'; adopt them separately",
in_pack.display()
)));
}
plans.push(AdoptPlan {
source: source.to_path_buf(),
in_pack: in_pack.to_path_buf(),
pack_dest,
is_dir,
destructive_overwrite: dest_exists,
});
Ok(())
}
fn absolutize(raw: &Path) -> Result<PathBuf> {
let abs = if raw.is_absolute() {
raw.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| DodotError::Fs {
path: raw.to_path_buf(),
source: e,
})?
.join(raw)
};
Ok(crate::equivalence::normalize_path(&abs))
}
fn check_writable(fs: &dyn Fs, dir: &Path) -> Result<()> {
let probe = dir.join(format!(".dodot-write-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_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 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);
}
}
struct ProspectiveTree<'a> {
pack_dir: &'a str,
prepared_root: &'a Path,
config_at: &'a Path,
superseded: &'a [PathBuf],
}
fn check_deploy_conflicts(ctx: &ExecutionContext, prospective: ProspectiveTree<'_>) -> 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();
let mut unresolved = 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 superseded: &[PathBuf] = if pack.path == prospective.config_at {
prospective.superseded
} else {
&[]
};
let plan = collect_intents_passive(&pack, &pack.path, ctx, superseded)?;
unresolved.extend(plan.unresolved_claims);
pack_intents.push((pack.display_name.clone(), plan.intents));
}
let mut prospective_pack = packs::Pack::new(
prospective.pack_dir.to_string(),
prospective.prepared_root.to_path_buf(),
Default::default(),
);
let pack_config = ctx.config_manager.config_for_pack(prospective.config_at)?;
prospective_pack.config = pack_config.to_handler_config();
let plan = collect_intents_passive(&prospective_pack, prospective.config_at, ctx, &[])?;
unresolved.extend(plan.unresolved_claims);
let display = prospective_pack.display_name.clone();
match pack_intents.iter_mut().find(|(name, _)| *name == display) {
Some((_, already)) => already.extend(plan.intents),
None => pack_intents.push((display, plan.intents)),
}
if !unresolved.is_empty() {
return Err(DodotError::ConflictCheckIncomplete { unresolved });
}
let conflicts = conflicts::detect_cross_pack_conflicts(&pack_intents, ctx.fs.as_ref());
if !conflicts.is_empty() {
return Err(DodotError::CrossPackConflict { conflicts });
}
Ok(())
}
fn collect_intents_passive(
pack: &packs::Pack,
config_at: &Path,
ctx: &ExecutionContext,
superseded: &[PathBuf],
) -> Result<orchestration::PackPlan> {
orchestration::plan_pack_without(
pack,
config_at,
ctx,
crate::preprocessing::PreprocessMode::Passive,
superseded,
)
}
struct AdoptFailure {
source: PathBuf,
reason: String,
stranded: Option<StrandedEntry>,
}
fn swap_all(
plans: &[AdoptPlan],
publication: &Publication,
pack_path: &Path,
fs: &dyn Fs,
) -> Vec<AdoptFailure> {
let mut failures: Vec<AdoptFailure> = Vec::new();
let mut failed: Vec<&AdoptPlan> = 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 {
failed.push(plan);
failures.push(AdoptFailure {
source: plan.source.clone(),
reason: err_msg(&e),
stranded: restore_failed_entry(plan, publication, fs),
});
}
}
if !failed.is_empty() {
prune_emptied_dirs(&failed, publication, pack_path, fs);
}
failures
}
fn restore_failed_entry(
plan: &AdoptPlan,
publication: &Publication,
fs: &dyn Fs,
) -> Option<StrandedEntry> {
let entry = match publication {
Publication::NewPack { published } => {
if still_published(fs, &plan.pack_dest, published.get(&plan.in_pack)) {
remove_best_effort(fs, &plan.pack_dest);
}
return occupied(fs, &plan.pack_dest).then(|| StrandedEntry {
in_pack: plan.in_pack.display().to_string(),
at: plan.pack_dest.display().to_string(),
});
}
Publication::Existing(record) => record.entries.iter().find(|e| e.in_pack == plan.in_pack),
};
let entry = entry?;
let in_pack = entry.in_pack.display().to_string();
let vacated = !entry.published || vacate(fs, entry);
match &entry.displaced {
Some(displaced) => {
if vacated && restore_displaced(fs, displaced, &entry.final_path) {
None
} else {
Some(StrandedEntry {
in_pack,
at: displaced.display().to_string(),
})
}
}
None => (!vacated).then(|| StrandedEntry {
in_pack,
at: entry.final_path.display().to_string(),
}),
}
}
fn vacate(fs: &dyn Fs, entry: &PublishedEntry) -> bool {
if still_published(fs, &entry.final_path, entry.identity.as_ref()) {
let _ = fs.rename_noreplace(&entry.final_path, &entry.prepared);
}
!occupied(fs, &entry.final_path)
}
fn published_identity(
fs: &dyn Fs,
prepared: Option<PreparedId>,
final_path: &Path,
) -> Option<PublishedId> {
let prepared = prepared?;
let published = fs.lstat(final_path).ok()?.id;
published
.same_entry(&prepared.entry)
.then_some(PublishedId {
entry: published,
descendants: prepared.descendants,
})
}
fn prepared_identity(fs: &dyn Fs, prepared: &Path) -> Option<PreparedId> {
Some(PreparedId {
entry: fs.lstat(prepared).ok()?.id,
descendants: subtree_ids(fs, prepared)?,
})
}
fn subtree_ids(fs: &dyn Fs, root: &Path) -> Option<Vec<(PathBuf, FileId)>> {
let mut ids = Vec::new();
collect_subtree_ids(fs, root, PathBuf::new(), &mut ids)?;
ids.sort_by(|a, b| a.0.cmp(&b.0));
Some(ids)
}
fn collect_subtree_ids(
fs: &dyn Fs,
path: &Path,
relative: PathBuf,
into: &mut Vec<(PathBuf, FileId)>,
) -> Option<()> {
let meta = fs.lstat(path).ok()?;
if !relative.as_os_str().is_empty() {
into.push((relative.clone(), meta.id));
}
if meta.is_dir && !meta.is_symlink {
for entry in fs.read_dir(path).ok()? {
collect_subtree_ids(fs, &entry.path, relative.join(&entry.name), into)?;
}
}
Some(())
}
fn still_published(fs: &dyn Fs, path: &Path, published: Option<&PublishedId>) -> bool {
let Some(published) = published else {
return false;
};
let Ok(now) = fs.lstat(path) else {
return false;
};
published.entry == now.id
&& subtree_ids(fs, path).is_some_and(|current| current == published.descendants)
}
fn restore_displaced(fs: &dyn Fs, displaced: &Path, final_path: &Path) -> bool {
fs.rename_noreplace(displaced, final_path).is_ok()
}
fn occupied(fs: &dyn Fs, path: &Path) -> bool {
fs.exists(path) || fs.is_symlink(path)
}
fn prune_emptied_dirs(
failed: &[&AdoptPlan],
publication: &Publication,
pack_path: &Path,
fs: &dyn Fs,
) {
match publication {
Publication::Existing(record) => {
for dir in record.created_dirs.iter().rev() {
let _ = fs.remove_dir_empty(dir);
}
}
Publication::NewPack { .. } => {
for plan in failed {
let mut dir = plan.pack_dest.parent().map(Path::to_path_buf);
while let Some(current) = dir {
if !current.starts_with(pack_path) || fs.remove_dir_empty(¤t).is_err() {
break;
}
if current == pack_path {
break;
}
dir = current.parent().map(Path::to_path_buf);
}
}
}
}
}
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) if fs.rename_noreplace(&backup, source).is_ok() => Err(e),
Err(e) => Err(DodotError::Other(format!(
"{e}; and the directory could not be moved back to {} — it is \
at {}",
source.display(),
backup.display()
))),
}
}
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::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let n = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{:x}-{:x}-{:x}", std::process::id(), seq, n)
}
fn err_msg(e: &DodotError) -> String {
format!("{e}")
}