feat(apply): consolidate logic in --dry-run
This commit is contained in:
parent
289aa82ded
commit
f23a9b2653
11 changed files with 268 additions and 183 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
use super::{find_config_file, parse_config, type_check};
|
use super::{find_config_file, parse_config, type_check};
|
||||||
use doot_core::state::{StateStore, SyncStatus};
|
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::ast::HookStage;
|
||||||
use doot_lang::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource, HookConfig};
|
use doot_lang::evaluator::{DotfileConfig, DotfilesPattern, DotfilesSource, HookConfig};
|
||||||
use doot_lang::{DotfileConflict, Evaluator, validate_dotfile_targets};
|
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() {
|
if full_source.is_dir() {
|
||||||
let changed_files = state.get_changed_files_in_dir(&full_source, &dotfile.target);
|
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_real_conflicts = false;
|
||||||
let mut has_changes = 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
|
let dry_prefix = if dry_run { "[dry-run] " } else { "" };
|
||||||
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(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run before_deploy hooks
|
// 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() {
|
if deploy_set.is_empty() {
|
||||||
println!("\nNothing to deploy (all files synced).");
|
println!("\n{}all dotfiles synced, nothing to deploy", dry_prefix);
|
||||||
} else {
|
} else {
|
||||||
// Filter parallel batches to only include items in deploy_set
|
// Filter parallel batches to only include items in deploy_set
|
||||||
let filtered_batches: Vec<Vec<usize>> = validation
|
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 deployer = Deployer::new(config, result.sandbox);
|
||||||
|
|
||||||
let pb = ProgressBar::new(deploy_set.len() as u64);
|
let progress = if !dry_run {
|
||||||
pb.set_style(
|
let pb = ProgressBar::new(deploy_set.len() as u64);
|
||||||
ProgressStyle::default_bar()
|
pb.set_style(
|
||||||
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
ProgressStyle::default_bar()
|
||||||
.unwrap()
|
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
|
||||||
.progress_chars("=>-"),
|
.unwrap()
|
||||||
);
|
.progress_chars("=>-"),
|
||||||
|
);
|
||||||
pb.set_message("deploying dotfiles");
|
pb.set_message("deploying dotfiles");
|
||||||
|
Some(pb)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let deploy_result =
|
let deploy_result =
|
||||||
deployer.deploy_batches(&result.dotfiles, &filtered_batches, Some(&pb))?;
|
deployer.deploy_batches(&result.dotfiles, &filtered_batches, progress.as_ref())?;
|
||||||
pb.finish_with_message("done");
|
|
||||||
|
|
||||||
println!("\ndeployment complete:");
|
if let Some(pb) = progress {
|
||||||
println!(" deployed: {}", deploy_result.deployed.len());
|
pb.finish_with_message("done");
|
||||||
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 {
|
if dry_run {
|
||||||
println!(" [skip] {} ({})", skipped.target.display(), skipped.reason);
|
// 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 {
|
if !deploy_result.errors.is_empty() {
|
||||||
tracing::error!(
|
println!("\n{}errors:", dry_prefix);
|
||||||
source = %error.source.display(),
|
for error in &deploy_result.errors {
|
||||||
target = %error.target.display(),
|
println!(" {} ({})", error.target.display(), error.error);
|
||||||
error = %error.error,
|
}
|
||||||
"deployment failed"
|
}
|
||||||
);
|
} 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 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() {
|
if !result.packages.is_empty() {
|
||||||
// Run before_package hooks
|
if !dry_run {
|
||||||
run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?;
|
run_hooks(&result.hooks, HookStage::BeforePackage, &hook_env)?;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(manager) = doot_core::package::detect_package_manager() {
|
if let Some(manager) = doot_core::package::detect_package_manager() {
|
||||||
// Filter out already installed packages
|
|
||||||
let mut to_install = Vec::new();
|
let mut to_install = Vec::new();
|
||||||
let mut already_installed = 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() {
|
if !already_installed.is_empty() {
|
||||||
tracing::debug!(
|
if dry_run {
|
||||||
count = already_installed.len(),
|
println!("\n{}packages already installed:", dry_prefix);
|
||||||
"packages already installed"
|
for pkg in &already_installed {
|
||||||
);
|
println!(" {}", pkg);
|
||||||
for pkg in &already_installed {
|
}
|
||||||
tracing::debug!(package = %pkg, "already installed");
|
} 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() {
|
if to_install.is_empty() {
|
||||||
println!(
|
if !dry_run {
|
||||||
"\nall {} packages already installed",
|
println!(
|
||||||
already_installed.len()
|
"\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 {
|
} else {
|
||||||
println!("\ninstalling {} packages...", to_install.len());
|
println!("\ninstalling {} packages...", to_install.len());
|
||||||
manager.install(&to_install)?;
|
manager.install(&to_install)?;
|
||||||
println!("installed {} packages", to_install.len());
|
println!("installed {} packages", to_install.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record all managed packages in state (both newly installed and already installed)
|
if !dry_run {
|
||||||
let mut state = StateStore::new(&state_file);
|
let mut state = StateStore::new(&state_file);
|
||||||
let manager_name = manager.name();
|
let manager_name = manager.name();
|
||||||
for pkg in to_install.iter().chain(already_installed.iter()) {
|
for pkg in to_install.iter().chain(already_installed.iter()) {
|
||||||
state.record_package(pkg, manager_name);
|
state.record_package(pkg, manager_name);
|
||||||
|
}
|
||||||
|
state.save()?;
|
||||||
}
|
}
|
||||||
state.save()?;
|
|
||||||
} else {
|
} else {
|
||||||
println!("no supported package manager found");
|
println!("no supported package manager found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run after_package hooks
|
if !dry_run {
|
||||||
run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?;
|
run_hooks(&result.hooks, HookStage::AfterPackage, &hook_env)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune packages removed from config
|
// Prune packages removed from config
|
||||||
|
|
@ -521,35 +525,42 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !to_prune.is_empty() {
|
if !to_prune.is_empty() {
|
||||||
println!("\n{} package(s) removed from config:", to_prune.len());
|
if dry_run {
|
||||||
for (name, _) in &to_prune {
|
println!("\n{}would uninstall removed packages:", dry_prefix);
|
||||||
println!(" {}", name);
|
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();
|
let mut uninstalled = Vec::new();
|
||||||
for (name, mgr_name) in &to_prune {
|
for (name, mgr_name) in &to_prune {
|
||||||
let should_uninstall = if prune { true } else { prompt_uninstall(name)? };
|
let should_uninstall = if prune { true } else { prompt_uninstall(name)? };
|
||||||
|
|
||||||
if should_uninstall {
|
if should_uninstall {
|
||||||
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
|
if let Some(mgr) = doot_core::package::get_package_manager(mgr_name) {
|
||||||
mgr.uninstall(std::slice::from_ref(name))?;
|
mgr.uninstall(std::slice::from_ref(name))?;
|
||||||
println!("uninstalled {}", name);
|
println!("uninstalled {}", name);
|
||||||
uninstalled.push(name.clone());
|
uninstalled.push(name.clone());
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
package = %name, manager = %mgr_name,
|
package = %name, manager = %mgr_name,
|
||||||
"cannot uninstall: package manager not available"
|
"cannot uninstall: package manager not available"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !uninstalled.is_empty() {
|
if !uninstalled.is_empty() {
|
||||||
let mut state = StateStore::new(&state_file);
|
let mut state = StateStore::new(&state_file);
|
||||||
for name in &uninstalled {
|
for name in &uninstalled {
|
||||||
state.remove_package(name);
|
state.remove_package(name);
|
||||||
|
}
|
||||||
|
state.save()?;
|
||||||
}
|
}
|
||||||
state.save()?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -767,6 +778,7 @@ fn expand_dotfile_patterns(
|
||||||
link_patterns: pattern.link_patterns.clone(),
|
link_patterns: pattern.link_patterns.clone(),
|
||||||
copy_patterns: pattern.copy_patterns.clone(),
|
copy_patterns: pattern.copy_patterns.clone(),
|
||||||
exclude_paths: vec![],
|
exclude_paths: vec![],
|
||||||
|
exclude_sources: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -775,9 +787,11 @@ fn expand_dotfile_patterns(
|
||||||
|
|
||||||
/// Merges explicit dotfile blocks into glob-expanded entries.
|
/// Merges explicit dotfile blocks into glob-expanded entries.
|
||||||
///
|
///
|
||||||
/// Two merge cases:
|
/// Three merge cases:
|
||||||
/// 1. Same target: explicit replaces glob-expanded entry entirely.
|
/// 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) {
|
fn merge_specializations(dotfiles: &mut Vec<DotfileConfig>, glob_count: usize) {
|
||||||
let total = dotfiles.len();
|
let total = dotfiles.len();
|
||||||
let explicit_end = total - glob_count;
|
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 exp_idx in 0..explicit_end {
|
||||||
for glob_idx in glob_start..total {
|
for glob_idx in glob_start..total {
|
||||||
if glob_to_remove.contains(&glob_idx) {
|
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 exp_target = dotfiles[exp_idx].target.clone();
|
||||||
let glob_target = &dotfiles[glob_idx].target;
|
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 {
|
if exp_target.starts_with(glob_target) && exp_target != *glob_target {
|
||||||
dotfiles[glob_idx].exclude_paths.push(exp_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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,9 @@ impl Deployer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.state.lock().unwrap().save()?;
|
if !self.config.dry_run {
|
||||||
|
self.state.lock().unwrap().save()?;
|
||||||
|
}
|
||||||
Ok(result)
|
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)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,16 +324,40 @@ impl Deployer {
|
||||||
let mut any_updated = false;
|
let mut any_updated = false;
|
||||||
let mut any_created = false;
|
let mut any_created = false;
|
||||||
|
|
||||||
for (src_file, tgt_file, status) in changed_files {
|
// Filter out excluded files before processing
|
||||||
// Skip files that have explicit specializations
|
let changed_files: Vec<_> = changed_files
|
||||||
if dotfile
|
.into_iter()
|
||||||
.exclude_paths
|
.filter(|(src_file, tgt_file, _)| {
|
||||||
.iter()
|
// Skip files excluded by target path
|
||||||
.any(|ex| tgt_file.starts_with(ex) || *ex == tgt_file)
|
if dotfile
|
||||||
{
|
.exclude_paths
|
||||||
continue;
|
.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 {
|
match status {
|
||||||
SyncStatus::NotDeployed
|
SyncStatus::NotDeployed
|
||||||
| SyncStatus::TargetMissing
|
| SyncStatus::TargetMissing
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pub mod package;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
pub use deploy::{DeployResult, Deployer};
|
pub use deploy::{DeployAction, DeployResult, Deployer};
|
||||||
pub use encryption::AgeEncryption;
|
pub use encryption::AgeEncryption;
|
||||||
pub use hooks::HookRunner;
|
pub use hooks::HookRunner;
|
||||||
pub use os::OsInfo;
|
pub use os::OsInfo;
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,12 @@ pub struct Dotfile {
|
||||||
pub deploy: DeployMode,
|
pub deploy: DeployMode,
|
||||||
pub link_patterns: Vec<String>,
|
pub link_patterns: Vec<String>,
|
||||||
pub copy_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.
|
/// Package installation declaration.
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ pub fn path_extension(args: &[Value]) -> Result<Value, EvalError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[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()))
|
Ok(Value::Path(dirs::home_dir().unwrap_or_default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ pub async fn call_builtin(
|
||||||
"path_parent" => io::path_parent(args),
|
"path_parent" => io::path_parent(args),
|
||||||
"path_filename" => io::path_filename(args),
|
"path_filename" => io::path_filename(args),
|
||||||
"path_extension" => io::path_extension(args),
|
"path_extension" => io::path_extension(args),
|
||||||
"home" => io::home(),
|
"home_dir" => io::home_dir(),
|
||||||
"config_dir" => io::config_dir(),
|
"config_dir" => io::config_dir(),
|
||||||
"data_dir" => io::data_dir(),
|
"data_dir" => io::data_dir(),
|
||||||
"cache_dir" => io::cache_dir(),
|
"cache_dir" => io::cache_dir(),
|
||||||
|
|
|
||||||
|
|
@ -319,8 +319,10 @@ pub struct DotfileConfig {
|
||||||
pub deploy: DeployMode,
|
pub deploy: DeployMode,
|
||||||
pub link_patterns: Vec<String>,
|
pub link_patterns: Vec<String>,
|
||||||
pub copy_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>,
|
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.
|
/// Evaluated package configuration.
|
||||||
|
|
@ -679,6 +681,7 @@ impl Evaluator {
|
||||||
link_patterns: dotfile.link_patterns.clone(),
|
link_patterns: dotfile.link_patterns.clone(),
|
||||||
copy_patterns: dotfile.copy_patterns.clone(),
|
copy_patterns: dotfile.copy_patterns.clone(),
|
||||||
exclude_paths: vec![],
|
exclude_paths: vec![],
|
||||||
|
exclude_sources: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ impl MacroExpander {
|
||||||
deploy: dotfile.deploy,
|
deploy: dotfile.deploy,
|
||||||
link_patterns: dotfile.link_patterns.clone(),
|
link_patterns: dotfile.link_patterns.clone(),
|
||||||
copy_patterns: dotfile.copy_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 {
|
Statement::Package(pkg) => Statement::Package(Box::new(Package {
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ impl Parser {
|
||||||
fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> {
|
fn dotfile_parser() -> impl chumsky::Parser<Token, Dotfile, Error = Simple<Token>> {
|
||||||
let field = Self::field_name_parser()
|
let field = Self::field_name_parser()
|
||||||
.then_ignore(just(Token::Eq))
|
.then_ignore(just(Token::Eq))
|
||||||
.then(Self::expr_parser());
|
.then(Self::expr_parser().map_with_span(|expr, span| (expr, span)));
|
||||||
|
|
||||||
just(Token::Dotfile)
|
just(Token::Dotfile)
|
||||||
.ignore_then(just(Token::Colon))
|
.ignore_then(just(Token::Colon))
|
||||||
|
|
@ -249,12 +249,24 @@ impl Parser {
|
||||||
deploy: DeployMode::default(),
|
deploy: DeployMode::default(),
|
||||||
link_patterns: Vec::new(),
|
link_patterns: Vec::new(),
|
||||||
copy_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() {
|
match name.as_str() {
|
||||||
"source" => dotfile.source = value,
|
"source" => {
|
||||||
"target" => dotfile.target = value,
|
dotfile.source = value;
|
||||||
"when" => dotfile.when = Some(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" => {
|
"template" => {
|
||||||
if let Expr::Literal(Literal::Bool(b)) = value {
|
if let Expr::Literal(Literal::Bool(b)) = value {
|
||||||
dotfile.template = Some(b);
|
dotfile.template = Some(b);
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,7 @@ mod tests {
|
||||||
link_patterns: Vec::new(),
|
link_patterns: Vec::new(),
|
||||||
copy_patterns: Vec::new(),
|
copy_patterns: Vec::new(),
|
||||||
exclude_paths: Vec::new(),
|
exclude_paths: Vec::new(),
|
||||||
|
exclude_sources: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,8 @@ impl TypeChecker {
|
||||||
}
|
}
|
||||||
|
|
||||||
Statement::Dotfile(dotfile) => {
|
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
|
// dotfile: source accepts path, str (pattern with wildcards), or list
|
||||||
if !matches!(
|
if !matches!(
|
||||||
source_ty,
|
source_ty,
|
||||||
|
|
@ -294,24 +295,26 @@ impl TypeChecker {
|
||||||
self.errors.push(TypeError::TypeMismatch {
|
self.errors.push(TypeError::TypeMismatch {
|
||||||
expected: "path, str, or [path]".to_string(),
|
expected: "path, str, or [path]".to_string(),
|
||||||
got: source_ty.display(),
|
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(_)) {
|
if matches!(target_ty, Type::List(_)) {
|
||||||
self.errors.push(TypeError::TypeMismatch {
|
self.errors.push(TypeError::TypeMismatch {
|
||||||
expected: "path".to_string(),
|
expected: "path".to_string(),
|
||||||
got: target_ty.display(),
|
got: target_ty.display(),
|
||||||
span: stmt.span.clone(),
|
span: target_span.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(ref when) = dotfile.when {
|
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) {
|
if !when_ty.is_compatible(&Type::Bool) {
|
||||||
self.errors.push(TypeError::TypeMismatch {
|
self.errors.push(TypeError::TypeMismatch {
|
||||||
expected: "bool".to_string(),
|
expected: "bool".to_string(),
|
||||||
got: when_ty.display(),
|
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,
|
"read_file" | "read_file_lines" => Type::Str,
|
||||||
"file_exists" | "dir_exists" | "is_symlink" => Type::Bool,
|
"file_exists" | "dir_exists" | "is_symlink" => Type::Bool,
|
||||||
"list_dir" | "walk_dir" => Type::List(Box::new(Type::Path)),
|
"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
|
Type::Path
|
||||||
}
|
}
|
||||||
"path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => {
|
"path_join" | "path_parent" | "path_filename" | "path_extension" | "read_link" => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue