feat(apply): consolidate logic in --dry-run

This commit is contained in:
Ray Sinurat 2026-02-06 15:40:03 -06:00
parent 289aa82ded
commit f23a9b2653
11 changed files with 268 additions and 183 deletions

View file

@ -1,6 +1,6 @@
use super::{find_config_file, parse_config, type_check};
use doot_core::state::{StateStore, SyncStatus};
use doot_core::{Config, Deployer};
use doot_core::{Config, DeployAction, Deployer};
use doot_lang::ast::HookStage;
use doot_lang::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource, HookConfig};
use doot_lang::{DotfileConflict, Evaluator, validate_dotfile_targets};
@ -121,6 +121,28 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
if full_source.is_dir() {
let changed_files = state.get_changed_files_in_dir(&full_source, &dotfile.target);
// Filter out excluded files before checking for changes
let changed_files: Vec<_> = changed_files
.into_iter()
.filter(|(src, tgt, _)| {
if dotfile
.exclude_paths
.iter()
.any(|ex| tgt.starts_with(ex) || ex == tgt)
{
return false;
}
if dotfile
.exclude_sources
.iter()
.any(|ex| src.strip_prefix(&full_source) == Ok(ex.as_path()))
{
return false;
}
true
})
.collect();
let mut has_real_conflicts = false;
let mut has_changes = false;
@ -310,85 +332,15 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
}
}
// Dry-run: show what would be done and exit
if dry_run {
if deploy_set.is_empty() {
println!("\n[dry-run] all dotfiles synced, nothing to deploy");
} else {
println!("\n[dry-run] would deploy:");
for &idx in &deploy_set {
let dotfile = &result.dotfiles[idx];
println!(
" {} -> {}",
dotfile.source.display(),
dotfile.target.display()
);
}
}
if !result.packages.is_empty() {
if let Some(manager) = doot_core::package::detect_package_manager() {
let mut to_install = Vec::new();
let mut already_installed = Vec::new();
for pkg in &result.packages {
if let Some(ref name) = pkg.default {
match manager.is_installed(name) {
Ok(true) => already_installed.push(name.clone()),
_ => to_install.push(name.clone()),
}
}
}
if !already_installed.is_empty() {
println!("\n[dry-run] packages already installed:");
for pkg in &already_installed {
println!(" {}", pkg);
}
}
if !to_install.is_empty() {
println!("\n[dry-run] would install packages:");
for pkg in &to_install {
println!(" {}", pkg);
}
} else if already_installed.is_empty() {
println!("\n[dry-run] no packages to install");
}
} else {
println!("\n[dry-run] no supported package manager found");
}
}
// Show packages that would be pruned
{
let configured_names: std::collections::HashSet<String> = result
.packages
.iter()
.filter_map(|p| p.default.clone())
.collect();
let state_for_prune = StateStore::new(&state_file);
let to_prune: Vec<_> = state_for_prune
.get_all_packages()
.iter()
.filter(|(name, _)| !configured_names.contains(*name))
.collect();
if !to_prune.is_empty() {
println!("\n[dry-run] would uninstall removed packages:");
for (name, _) in &to_prune {
println!(" {}", name);
}
}
}
return Ok(());
}
let dry_prefix = if dry_run { "[dry-run] " } else { "" };
// Run before_deploy hooks
run_hooks(&result.hooks, HookStage::BeforeDeploy, &hook_env)?;
if !dry_run {
run_hooks(&result.hooks, HookStage::BeforeDeploy, &hook_env)?;
}
if deploy_set.is_empty() {
println!("\nNothing to deploy (all files synced).");
println!("\n{}all dotfiles synced, nothing to deploy", dry_prefix);
} else {
// Filter parallel batches to only include items in deploy_set
let filtered_batches: Vec<Vec<usize>> = validation
@ -406,56 +358,92 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
let deployer = Deployer::new(config, result.sandbox);
let pb = ProgressBar::new(deploy_set.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("=>-"),
);
pb.set_message("deploying dotfiles");
let progress = if !dry_run {
let pb = ProgressBar::new(deploy_set.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("=>-"),
);
pb.set_message("deploying dotfiles");
Some(pb)
} else {
None
};
let deploy_result =
deployer.deploy_batches(&result.dotfiles, &filtered_batches, Some(&pb))?;
pb.finish_with_message("done");
deployer.deploy_batches(&result.dotfiles, &filtered_batches, progress.as_ref())?;
println!("\ndeployment complete:");
println!(" deployed: {}", deploy_result.deployed.len());
println!(" skipped: {}", deploy_result.skipped.len());
println!(" errors: {}", deploy_result.errors.len());
for deployed in &deploy_result.deployed {
tracing::debug!(
source = %deployed.source.display(),
target = %deployed.target.display(),
"deployed"
);
if let Some(pb) = progress {
pb.finish_with_message("done");
}
for skipped in &deploy_result.skipped {
println!(" [skip] {} ({})", skipped.target.display(), skipped.reason);
}
if dry_run {
// Dry-run: show what the conflict-check decided needs deploying
println!("\n{}would deploy:", dry_prefix);
for &idx in &deploy_set {
let dotfile = &result.dotfiles[idx];
println!(
" {} -> {}",
dotfile.source.display(),
dotfile.target.display()
);
}
for error in &deploy_result.errors {
tracing::error!(
source = %error.source.display(),
target = %error.target.display(),
error = %error.error,
"deployment failed"
);
if !deploy_result.errors.is_empty() {
println!("\n{}errors:", dry_prefix);
for error in &deploy_result.errors {
println!(" {} ({})", error.target.display(), error.error);
}
}
} else {
let active: Vec<_> = deploy_result
.deployed
.iter()
.filter(|d| !matches!(d.action, DeployAction::Unchanged))
.collect();
println!("\ndeployment complete:");
println!(" deployed: {}", active.len());
println!(" skipped: {}", deploy_result.skipped.len());
println!(" errors: {}", deploy_result.errors.len());
for deployed in &deploy_result.deployed {
tracing::debug!(
source = %deployed.source.display(),
target = %deployed.target.display(),
"deployed"
);
}
for skipped in &deploy_result.skipped {
println!(" [skip] {} ({})", skipped.target.display(), skipped.reason);
}
for error in &deploy_result.errors {
tracing::error!(
source = %error.source.display(),
target = %error.target.display(),
error = %error.error,
"deployment failed"
);
}
}
}
// Run after_deploy hooks
run_hooks(&result.hooks, HookStage::AfterDeploy, &hook_env)?;
if !dry_run {
run_hooks(&result.hooks, HookStage::AfterDeploy, &hook_env)?;
}
// Package handling
if !result.packages.is_empty() {
// Run before_package hooks
run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?;
if !dry_run {
run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?;
}
if let Some(manager) = doot_core::package::detect_package_manager() {
// Filter out already installed packages
let mut to_install = Vec::new();
let mut already_installed = Vec::new();
@ -469,39 +457,55 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
}
if !already_installed.is_empty() {
tracing::debug!(
count = already_installed.len(),
"packages already installed"
);
for pkg in &already_installed {
tracing::debug!(package = %pkg, "already installed");
if dry_run {
println!("\n{}packages already installed:", dry_prefix);
for pkg in &already_installed {
println!(" {}", pkg);
}
} else {
tracing::debug!(
count = already_installed.len(),
"packages already installed"
);
for pkg in &already_installed {
tracing::debug!(package = %pkg, "already installed");
}
}
}
if to_install.is_empty() {
println!(
"\nall {} packages already installed",
already_installed.len()
);
if !dry_run {
println!(
"\nall {} packages already installed",
already_installed.len()
);
}
} else if dry_run {
println!("\n{}would install packages:", dry_prefix);
for pkg in &to_install {
println!(" {}", pkg);
}
} else {
println!("\ninstalling {} packages...", to_install.len());
manager.install(&to_install)?;
println!("installed {} packages", to_install.len());
}
// Record all managed packages in state (both newly installed and already installed)
let mut state = StateStore::new(&state_file);
let manager_name = manager.name();
for pkg in to_install.iter().chain(already_installed.iter()) {
state.record_package(pkg, manager_name);
if !dry_run {
let mut state = StateStore::new(&state_file);
let manager_name = manager.name();
for pkg in to_install.iter().chain(already_installed.iter()) {
state.record_package(pkg, manager_name);
}
state.save()?;
}
state.save()?;
} else {
println!("no supported package manager found");
}
// Run after_package hooks
run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?;
if !dry_run {
run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?;
}
}
// Prune packages removed from config
@ -521,35 +525,42 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
.collect();
if !to_prune.is_empty() {
println!("\n{} package(s) removed from config:", to_prune.len());
for (name, _) in &to_prune {
println!(" {}", name);
}
if dry_run {
println!("\n{}would uninstall removed packages:", dry_prefix);
for (name, _) in &to_prune {
println!(" {}", name);
}
} else {
println!("\n{} package(s) removed from config:", to_prune.len());
for (name, _) in &to_prune {
println!(" {}", name);
}
let mut uninstalled = Vec::new();
for (name, mgr_name) in &to_prune {
let should_uninstall = if prune { true } else { prompt_uninstall(name)? };
let mut uninstalled = Vec::new();
for (name, mgr_name) in &to_prune {
let should_uninstall = if prune { true } else { prompt_uninstall(name)? };
if should_uninstall {
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
mgr.uninstall(std::slice::from_ref(name))?;
println!("uninstalled {}", name);
uninstalled.push(name.clone());
} else {
tracing::warn!(
package = %name, manager = %mgr_name,
"cannot uninstall: package manager not available"
);
if should_uninstall {
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
mgr.uninstall(std::slice::from_ref(name))?;
println!("uninstalled {}", name);
uninstalled.push(name.clone());
} else {
tracing::warn!(
package = %name, manager = %mgr_name,
"cannot uninstall: package manager not available"
);
}
}
}
}
if !uninstalled.is_empty() {
let mut state = StateStore::new(&state_file);
for name in &uninstalled {
state.remove_package(name);
if !uninstalled.is_empty() {
let mut state = StateStore::new(&state_file);
for name in &uninstalled {
state.remove_package(name);
}
state.save()?;
}
state.save()?;
}
}
}
@ -767,6 +778,7 @@ fn expand_dotfile_patterns(
link_patterns: pattern.link_patterns.clone(),
copy_patterns: pattern.copy_patterns.clone(),
exclude_paths: vec![],
exclude_sources: vec![],
});
}
}
@ -775,9 +787,11 @@ fn expand_dotfile_patterns(
/// Merges explicit dotfile blocks into glob-expanded entries.
///
/// Two merge cases:
/// Three merge cases:
/// 1. Same target: explicit replaces glob-expanded entry entirely.
/// 2. File inside directory: adds the file's target to the directory entry's exclude_paths.
/// 2. Target inside directory target: adds the file's target to exclude_paths.
/// 3. Source inside directory source: adds the file's source to exclude_sources
/// (handles cases where the explicit block targets a different location).
fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
let total = dotfiles.len();
let explicit_end = total - glob_count;
@ -801,7 +815,7 @@ fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
}
}
// Second pass: collect exclude_paths for directory entries
// Second pass: collect exclusions for directory entries
for exp_idx in 0..explicit_end {
for glob_idx in glob_start..total {
if glob_to_remove.contains(&glob_idx) {
@ -811,9 +825,24 @@ fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
let exp_target = dotfiles[exp_idx].target.clone();
let glob_target = &dotfiles[glob_idx].target;
// Target-based exclusion: explicit target is inside glob target directory
if exp_target.starts_with(glob_target) && exp_target != *glob_target {
dotfiles[glob_idx].exclude_paths.push(exp_target);
}
// Source-based exclusion: explicit source is inside glob source directory
// Store path relative to the directory source so it matches after strip_prefix
let exp_source = dotfiles[exp_idx].source.clone();
let glob_source = dotfiles[glob_idx].source.clone();
if exp_source.starts_with(&glob_source)
&& exp_source != glob_source
&& let Ok(relative) = exp_source.strip_prefix(&glob_source)
{
dotfiles[glob_idx]
.exclude_sources
.push(relative.to_path_buf());
}
}
}

View file

@ -165,7 +165,9 @@ impl Deployer {
}
}
self.state.lock().unwrap().save()?;
if !self.config.dry_run {
self.state.lock().unwrap().save()?;
}
Ok(result)
}
@ -220,7 +222,9 @@ impl Deployer {
}
}
self.state.lock().unwrap().save()?;
if !self.config.dry_run {
self.state.lock().unwrap().save()?;
}
Ok(result)
}
@ -320,16 +324,40 @@ impl Deployer {
let mut any_updated = false;
let mut any_created = false;
for (src_file, tgt_file, status) in changed_files {
// Skip files that have explicit specializations
if dotfile
.exclude_paths
.iter()
.any(|ex| tgt_file.starts_with(ex) || *ex == tgt_file)
{
continue;
}
// Filter out excluded files before processing
let changed_files: Vec<_> = changed_files
.into_iter()
.filter(|(src_file, tgt_file, _)| {
// Skip files excluded by target path
if dotfile
.exclude_paths
.iter()
.any(|ex| tgt_file.starts_with(ex) || ex == tgt_file)
{
return false;
}
// Skip files excluded by source path
if dotfile
.exclude_sources
.iter()
.any(|ex| src_file.strip_prefix(source) == Ok(ex.as_path()))
{
return false;
}
true
})
.collect();
// If all files are excluded, skip creating the directory entirely
if changed_files.is_empty() {
return Ok(DeployedFile {
source: source.to_path_buf(),
target: target.to_path_buf(),
action: DeployAction::Unchanged,
});
}
for (src_file, tgt_file, status) in changed_files {
match status {
SyncStatus::NotDeployed
| SyncStatus::TargetMissing

View file

@ -12,7 +12,7 @@ pub mod package;
pub mod state;
pub use config::Config;
pub use deploy::{DeployResult, Deployer};
pub use deploy::{DeployAction, DeployResult, Deployer};
pub use encryption::AgeEncryption;
pub use hooks::HookRunner;
pub use os::OsInfo;

View file

@ -145,6 +145,12 @@ pub struct Dotfile {
pub deploy: DeployMode,
pub link_patterns: Vec<String>,
pub copy_patterns: Vec<String>,
/// Span of the source expression (for error reporting).
pub source_span: Option<Span>,
/// Span of the target expression (for error reporting).
pub target_span: Option<Span>,
/// Span of the when expression (for error reporting).
pub when_span: Option<Span>,
}
/// Package installation declaration.

View file

@ -174,7 +174,7 @@ pub fn path_extension(args: &[Value]) -> Result<Value, EvalError> {
}
#[tracing::instrument(level = "trace", skip_all)]
pub fn home() -> Result<Value, EvalError> {
pub fn home_dir() -> Result<Value, EvalError> {
Ok(Value::Path(dirs::home_dir().unwrap_or_default()))
}

View file

@ -80,7 +80,7 @@ pub async fn call_builtin(
"path_parent" => io::path_parent(args),
"path_filename" => io::path_filename(args),
"path_extension" => io::path_extension(args),
"home" => io::home(),
"home_dir" => io::home_dir(),
"config_dir" => io::config_dir(),
"data_dir" => io::data_dir(),
"cache_dir" => io::cache_dir(),

View file

@ -319,8 +319,10 @@ pub struct DotfileConfig {
pub deploy: DeployMode,
pub link_patterns: Vec<String>,
pub copy_patterns: Vec<String>,
/// Files to skip during directory deploy (specialized by explicit dotfile blocks).
/// Target paths to skip during directory deploy (specialized by explicit dotfile blocks).
pub exclude_paths: Vec<PathBuf>,
/// Source paths to skip during directory deploy (when explicit block targets elsewhere).
pub exclude_sources: Vec<PathBuf>,
}
/// Evaluated package configuration.
@ -679,6 +681,7 @@ impl Evaluator {
link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(),
exclude_paths: vec![],
exclude_sources: vec![],
});
}
}

View file

@ -66,6 +66,9 @@ impl MacroExpander {
deploy: dotfile.deploy,
link_patterns: dotfile.link_patterns.clone(),
copy_patterns: dotfile.copy_patterns.clone(),
source_span: dotfile.source_span.clone(),
target_span: dotfile.target_span.clone(),
when_span: dotfile.when_span.clone(),
}),
Statement::Package(pkg) => Statement::Package(Box::new(Package {

View file

@ -224,7 +224,7 @@ impl Parser {
fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> {
let field = Self::field_name_parser()
.then_ignore(just(Token::Eq))
.then(Self::expr_parser());
.then(Self::expr_parser().map_with_span(|expr, span| (expr, span)));
just(Token::Dotfile)
.ignore_then(just(Token::Colon))
@ -249,12 +249,24 @@ impl Parser {
deploy: DeployMode::default(),
link_patterns: Vec::new(),
copy_patterns: Vec::new(),
source_span: None,
target_span: None,
when_span: None,
};
for (name, value) in fields {
for (name, (value, span)) in fields {
match name.as_str() {
"source" => dotfile.source = value,
"target" => dotfile.target = value,
"when" => dotfile.when = Some(value),
"source" => {
dotfile.source = value;
dotfile.source_span = Some(span);
}
"target" => {
dotfile.target = value;
dotfile.target_span = Some(span);
}
"when" => {
dotfile.when = Some(value);
dotfile.when_span = Some(span);
}
"template" => {
if let Expr::Literal(Literal::Bool(b)) = value {
dotfile.template = Some(b);

View file

@ -297,6 +297,7 @@ mod tests {
link_patterns: Vec::new(),
copy_patterns: Vec::new(),
exclude_paths: Vec::new(),
exclude_sources: Vec::new(),
}
}

View file

@ -285,7 +285,8 @@ impl TypeChecker {
}
Statement::Dotfile(dotfile) => {
let source_ty = self.infer_expr(&dotfile.source, &stmt.span);
let source_span = dotfile.source_span.as_ref().unwrap_or(&stmt.span);
let source_ty = self.infer_expr(&dotfile.source, source_span);
// dotfile: source accepts path, str (pattern with wildcards), or list
if !matches!(
source_ty,
@ -294,24 +295,26 @@ impl TypeChecker {
self.errors.push(TypeError::TypeMismatch {
expected: "path, str, or [path]".to_string(),
got: source_ty.display(),
span: stmt.span.clone(),
span: source_span.clone(),
});
}
let target_ty = self.infer_expr(&dotfile.target, &stmt.span);
let target_span = dotfile.target_span.as_ref().unwrap_or(&stmt.span);
let target_ty = self.infer_expr(&dotfile.target, target_span);
if matches!(target_ty, Type::List(_)) {
self.errors.push(TypeError::TypeMismatch {
expected: "path".to_string(),
got: target_ty.display(),
span: stmt.span.clone(),
span: target_span.clone(),
});
}
if let Some(ref when) = dotfile.when {
let when_ty = self.infer_expr(when, &stmt.span);
let when_span = dotfile.when_span.as_ref().unwrap_or(&stmt.span);
let when_ty = self.infer_expr(when, when_span);
if !when_ty.is_compatible(&Type::Bool) {
self.errors.push(TypeError::TypeMismatch {
expected: "bool".to_string(),
got: when_ty.display(),
span: stmt.span.clone(),
span: when_span.clone(),
});
}
}
@ -739,7 +742,7 @@ impl TypeChecker {
"read_file" | "read_file_lines" => Type::Str,
"file_exists" | "dir_exists" | "is_symlink" => Type::Bool,
"list_dir" | "walk_dir" => Type::List(Box::new(Type::Path)),
"home" | "config_dir" | "data_dir" | "cache_dir" | "temp_dir" | "temp_file" => {
"home_dir" | "config_dir" | "data_dir" | "cache_dir" | "temp_dir" | "temp_file" => {
Type::Path
}
"path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => {