fix(bugs): bugs in template apply

This commit is contained in:
Ray Andrew 2026-02-20 18:44:48 -06:00
parent a7548dc356
commit 77b25771c3
Signed by: rayandrew
SSH key fingerprint: SHA256:EUCV+qCSqkap8rR+p+zGjxHfKI06G0GJKgo1DIOniQY
3 changed files with 155 additions and 17 deletions

View file

@ -1,4 +1,5 @@
use super::{find_config_file, parse_config, type_check}; use super::{decrypt_encrypted_vars_with_source_dir, find_config_file, parse_config, type_check};
use doot_core::deploy::TemplateEngine;
use doot_core::state::{StateStore, SyncStatus}; use doot_core::state::{StateStore, SyncStatus};
use doot_core::{Config, DeployAction, Deployer}; use doot_core::{Config, DeployAction, Deployer};
use doot_lang::ast::HookStage; use doot_lang::ast::HookStage;
@ -99,6 +100,19 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
let state_file = config.state_file.clone(); let state_file = config.state_file.clone();
let state = StateStore::new(&state_file); let state = StateStore::new(&state_file);
// Prepare template variables early for use in both deployer and preview
let mut template_vars = evaluator.get_template_variables();
decrypt_encrypted_vars_with_source_dir(
&result,
&config,
&mut template_vars,
Some(&source_dir),
)?;
// Initialize preview TemplateEngine
let mut preview_engine = TemplateEngine::new();
preview_engine.set_doot_variables(&template_vars);
// Check for conflicts before deploying (track by original index) // Check for conflicts before deploying (track by original index)
let mut deploy_set: HashSet<usize> = HashSet::new(); let mut deploy_set: HashSet<usize> = HashSet::new();
let mut conflicts: Vec<(usize, SyncStatus)> = Vec::new(); let mut conflicts: Vec<(usize, SyncStatus)> = Vec::new();
@ -195,7 +209,16 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
} }
} else { } else {
// Single file handling // Single file handling
match status { // Check for template rendering drift when sync status is Synced and file is a template
let mut final_status = status;
if status == SyncStatus::Synced
&& dotfile.template
&& template_outdated(&state, &preview_engine, &full_source, &dotfile.target)?
{
final_status = SyncStatus::SourceChanged;
}
match final_status {
SyncStatus::Synced => { SyncStatus::Synced => {
tracing::debug!(target = %dotfile.target.display(), "synced"); tracing::debug!(target = %dotfile.target.display(), "synced");
} }
@ -435,13 +458,6 @@ pub fn run(config_path: Option<PathBuf>, dry_run: bool, prune: bool) -> anyhow::
.filter(|batch| !batch.is_empty()) .filter(|batch| !batch.is_empty())
.collect(); .collect();
let mut template_vars = evaluator.get_template_variables();
super::decrypt_encrypted_vars_with_source_dir(
&result,
&config,
&mut template_vars,
Some(&source_dir),
)?;
let deployer = Deployer::new(config, result.sandbox, Some(&template_vars)); let deployer = Deployer::new(config, result.sandbox, Some(&template_vars));
let progress = if !dry_run { let progress = if !dry_run {
@ -869,6 +885,48 @@ fn common_path_prefix(paths: &[PathBuf]) -> PathBuf {
prefix prefix
} }
/// Renders a template file and returns its BLAKE3 hash.
fn rendered_template_hash(
engine: &TemplateEngine,
source_path: &Path,
) -> anyhow::Result<Option<String>> {
if !source_path.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(source_path)?;
let rendered = engine
.render(&content)
.map_err(|e| anyhow::anyhow!("template render error: {}", e))?;
let hash = blake3::hash(rendered.as_bytes()).to_hex().to_string();
Ok(Some(hash))
}
/// Returns true if the rendered template output differs from the last deployed content.
pub(crate) fn template_outdated(
state: &StateStore,
engine: &TemplateEngine,
source_path: &Path,
target_path: &Path,
) -> anyhow::Result<bool> {
if !source_path.is_file() {
return Ok(false);
}
let Some(record) = state.get_deployment(target_path) else {
return Ok(false);
};
if !record.template {
return Ok(false);
}
if let Some(rendered_hash) = rendered_template_hash(engine, source_path)? {
return Ok(rendered_hash != record.target_hash);
}
Ok(false)
}
/// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig entries. /// Expands dotfile patterns (from `dotfiles:` blocks) into individual DotfileConfig entries.
/// Returns the number of entries added. /// Returns the number of entries added.
fn expand_dotfile_patterns( fn expand_dotfile_patterns(

View file

@ -1,4 +1,9 @@
use super::{find_config_file, parse_config, type_check}; use super::{
apply::template_outdated, decrypt_encrypted_vars_with_source_dir, find_config_file,
parse_config, type_check,
};
use doot_core::Config;
use doot_core::deploy::TemplateEngine;
use doot_core::state::{StateStore, SyncStatus}; use doot_core::state::{StateStore, SyncStatus};
use doot_lang::Evaluator; use doot_lang::Evaluator;
use std::path::PathBuf; use std::path::PathBuf;
@ -16,9 +21,24 @@ pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone());
let result = evaluator.eval_sync(&program)?; let result = evaluator.eval_sync(&program)?;
let state_file = source_dir.join(".doot-state.json");
// Prepare template variables early for preview rendering
let config = Config::new(source_dir.clone());
let state_file = config.state_file.clone();
let state = StateStore::new(&state_file); let state = StateStore::new(&state_file);
let mut template_vars = evaluator.get_template_variables();
decrypt_encrypted_vars_with_source_dir(
&result,
&config,
&mut template_vars,
Some(&source_dir),
)?;
// Initialize preview TemplateEngine
let mut preview_engine = TemplateEngine::new();
preview_engine.set_doot_variables(&template_vars);
println!("doot status"); println!("doot status");
println!("===========\n"); println!("===========\n");
@ -27,7 +47,7 @@ pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
let target = &dotfile.target; let target = &dotfile.target;
let full_source = source_dir.join(&dotfile.source); let full_source = source_dir.join(&dotfile.source);
let sync = state.check_sync_status_with_config( let mut sync = state.check_sync_status_with_config(
&full_source, &full_source,
target, target,
Some(dotfile.template), Some(dotfile.template),
@ -36,6 +56,13 @@ pub fn run(config_path: Option<PathBuf>) -> anyhow::Result<()> {
dotfile.owner.as_deref(), dotfile.owner.as_deref(),
); );
// Check for template rendering changes
if sync == SyncStatus::Synced && dotfile.template {
if template_outdated(&state, &preview_engine, &full_source, target)? {
sync = SyncStatus::SourceChanged;
}
}
let (status, extra) = if target.is_symlink() { let (status, extra) = if target.is_symlink() {
let link_target = std::fs::read_link(target).ok(); let link_target = std::fs::read_link(target).ok();
if link_target.as_ref() == Some(&full_source) { if link_target.as_ref() == Some(&full_source) {

View file

@ -15,14 +15,22 @@ impl Sandbox {
Self { path } Self { path }
} }
fn run(&self, args: &[&str]) -> std::process::Output { fn run_with_env<F>(&self, args: &[&str], configure: F) -> std::process::Output
where
F: FnOnce(&mut Command),
{
let doot = env!("CARGO_BIN_EXE_doot"); let doot = env!("CARGO_BIN_EXE_doot");
Command::new(doot) let mut command = Command::new(doot);
command
.args(args) .args(args)
.env("DOOT_HOME", &self.path) .env("DOOT_HOME", &self.path)
.env("DOOT_TEST_MODE", "1") .env("DOOT_TEST_MODE", "1");
.output() configure(&mut command);
.expect("failed to run doot") command.output().expect("failed to run doot")
}
fn run(&self, args: &[&str]) -> std::process::Output {
self.run_with_env(args, |_| {})
} }
fn config_dir(&self) -> PathBuf { fn config_dir(&self) -> PathBuf {
@ -213,6 +221,51 @@ fn test_apply_copy_unchanged_on_rerun() {
assert!(!target.is_symlink(), "target should still be a copy"); assert!(!target.is_symlink(), "target should still be a copy");
} }
#[test]
fn test_template_redeploys_when_env_changes() {
let sandbox = Sandbox::new("template-env-change");
sandbox.write_config(
r#"
dotfile:
source = "templates/app.conf"
target = "~/.config/app/app.conf"
template = true
"#,
);
sandbox.write_source("templates/app.conf", "value = {{ env.TEMPLATE_VAL }}\n");
let first = sandbox.run_with_env(&["apply"], |cmd| {
cmd.env("TEMPLATE_VAL", "one");
});
assert!(first.status.success(), "first apply failed: {:?}", first);
let target = sandbox.path.join(".config/app/app.conf");
assert!(target.exists(), "template target missing after first apply");
let first_content = std::fs::read_to_string(&target).unwrap();
assert!(
first_content.contains("one"),
"expected first render to contain env value"
);
let second = sandbox.run_with_env(&["apply"], |cmd| {
cmd.env("TEMPLATE_VAL", "two");
});
assert!(second.status.success(), "second apply failed: {:?}", second);
let stdout = String::from_utf8_lossy(&second.stdout);
assert!(
stdout.contains("[source changed]"),
"expected source change notice, got: {}",
stdout
);
let updated = std::fs::read_to_string(&target).unwrap();
assert!(
updated.contains("two"),
"template should reflect new env value"
);
}
#[test] #[test]
fn test_status_shows_state() { fn test_status_shows_state() {
let sandbox = Sandbox::new("status"); let sandbox = Sandbox::new("status");