use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::preprocessing::conflict::find_unresolved_marker_lines;
use crate::preprocessing::divergence::{
classify_one, collect_baselines, DivergenceReport, DivergenceState,
};
use crate::preprocessing::no_reverse::is_no_reverse;
use crate::preprocessing::reverse_merge::{reverse_merge, ReverseMergeOutcome};
use crate::Result;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TransformAction {
Synced,
InputChanged,
Patched,
Conflict,
NeedsRebaseline,
MissingSource,
MissingDeployed,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformCheckEntry {
pub pack: String,
pub handler: String,
pub filename: String,
pub source_path: String,
pub deployed_path: String,
pub action: TransformAction,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub conflict_block: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedMarkerEntry {
pub source_path: String,
pub line_numbers: Vec<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformCheckResult {
pub entries: Vec<TransformCheckEntry>,
pub unresolved_markers: Vec<UnresolvedMarkerEntry>,
pub has_findings: bool,
pub strict: bool,
}
impl TransformCheckResult {
pub fn exit_code(&self) -> i32 {
if self.has_findings {
1
} else {
0
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformStatusEntry {
pub pack: String,
pub handler: String,
pub filename: String,
pub source_path: String,
pub deployed_path: String,
#[serde(rename = "state")]
pub state: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformStatusResult {
pub entries: Vec<TransformStatusEntry>,
pub synced_count: usize,
pub diverged_count: usize,
pub missing_count: usize,
}
pub fn status(ctx: &ExecutionContext) -> Result<TransformStatusResult> {
use crate::preprocessing::divergence::{collect_divergences, DivergenceState};
let reports = collect_divergences(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let mut synced_count = 0usize;
let mut diverged_count = 0usize;
let mut missing_count = 0usize;
let entries: Vec<TransformStatusEntry> = reports
.into_iter()
.map(|r| {
let state_str = match r.state {
DivergenceState::Synced => {
synced_count += 1;
"synced"
}
DivergenceState::InputChanged => {
diverged_count += 1;
"input_changed"
}
DivergenceState::OutputChanged => {
diverged_count += 1;
"output_changed"
}
DivergenceState::BothChanged => {
diverged_count += 1;
"both_changed"
}
DivergenceState::MissingSource => {
missing_count += 1;
"missing_source"
}
DivergenceState::MissingDeployed => {
missing_count += 1;
"missing_deployed"
}
};
TransformStatusEntry {
pack: r.pack,
handler: r.handler,
filename: r.filename,
source_path: render_path(&r.source_path, ctx.paths.home_dir()),
deployed_path: render_path(&r.deployed_path, ctx.paths.home_dir()),
state: state_str.to_string(),
}
})
.collect();
Ok(TransformStatusResult {
entries,
synced_count,
diverged_count,
missing_count,
})
}
pub fn check(ctx: &ExecutionContext, strict: bool) -> Result<TransformCheckResult> {
let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let mut entries: Vec<TransformCheckEntry> = Vec::with_capacity(baselines.len());
let mut has_findings = false;
let mut no_reverse_cache: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (pack, handler, filename, baseline) in baselines {
let report = classify_one(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
&pack,
&handler,
&filename,
&baseline,
);
let no_reverse_patterns = no_reverse_cache
.entry(pack.clone())
.or_insert_with(|| pack_no_reverse_patterns(ctx, &pack));
let no_reverse = is_no_reverse(&report.source_path, no_reverse_patterns);
let action = match report.state {
DivergenceState::Synced => TransformAction::Synced,
DivergenceState::InputChanged => TransformAction::InputChanged,
DivergenceState::MissingSource => {
has_findings = true;
TransformAction::MissingSource
}
DivergenceState::MissingDeployed => {
has_findings = true;
TransformAction::MissingDeployed
}
DivergenceState::OutputChanged | DivergenceState::BothChanged if no_reverse => {
TransformAction::Synced
}
DivergenceState::OutputChanged | DivergenceState::BothChanged => {
if baseline.tracked_render.is_empty() {
has_findings = true;
TransformAction::NeedsRebaseline
} else {
let template_src = ctx.fs.read_to_string(&report.source_path)?;
let deployed = ctx.fs.read_to_string(&report.deployed_path)?;
match reverse_merge(&template_src, &baseline.tracked_render, &deployed)? {
ReverseMergeOutcome::Unchanged => TransformAction::Synced,
ReverseMergeOutcome::Patched(patched) => {
if !ctx.dry_run {
ctx.fs.write_file(&report.source_path, patched.as_bytes())?;
}
TransformAction::Patched
}
ReverseMergeOutcome::Conflict(block) => {
has_findings = true;
return_conflict_entry(
&mut entries,
report,
block,
ctx.paths.home_dir(),
);
continue;
}
}
}
}
};
entries.push(make_entry(report, action, ctx.paths.home_dir()));
}
let mut unresolved_markers = Vec::new();
if strict {
let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
for (_pack, _handler, _filename, baseline) in baselines {
if baseline.source_path.as_os_str().is_empty() || !ctx.fs.exists(&baseline.source_path)
{
continue;
}
let bytes = ctx.fs.read_file(&baseline.source_path)?;
let content = String::from_utf8_lossy(&bytes);
let lines = find_unresolved_marker_lines(&content);
if !lines.is_empty() {
has_findings = true;
unresolved_markers.push(UnresolvedMarkerEntry {
source_path: render_path(&baseline.source_path, ctx.paths.home_dir()),
line_numbers: lines.iter().map(|(n, _)| *n).collect(),
});
}
}
}
Ok(TransformCheckResult {
entries,
unresolved_markers,
has_findings,
strict,
})
}
fn make_entry(
report: DivergenceReport,
action: TransformAction,
home: &std::path::Path,
) -> TransformCheckEntry {
TransformCheckEntry {
pack: report.pack,
handler: report.handler,
filename: report.filename,
source_path: render_path(&report.source_path, home),
deployed_path: render_path(&report.deployed_path, home),
action,
conflict_block: String::new(),
}
}
fn return_conflict_entry(
entries: &mut Vec<TransformCheckEntry>,
report: DivergenceReport,
block: String,
home: &std::path::Path,
) {
entries.push(TransformCheckEntry {
pack: report.pack,
handler: report.handler,
filename: report.filename,
source_path: render_path(&report.source_path, home),
deployed_path: render_path(&report.deployed_path, home),
action: TransformAction::Conflict,
conflict_block: block,
});
}
fn render_path(p: &std::path::Path, home: &std::path::Path) -> String {
if let Ok(rel) = p.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
p.display().to_string()
}
}
fn pack_no_reverse_patterns(ctx: &ExecutionContext, pack: &str) -> Vec<String> {
let pack_path = ctx.paths.dotfiles_root().join(pack);
match ctx.config_manager.config_for_pack(&pack_path) {
Ok(cfg) => cfg.preprocessor.template.no_reverse.clone(),
Err(_) => Vec::new(),
}
}
pub(crate) const HOOK_GUARD_START: &str =
"# >>> dodot transform check --strict (managed by `dodot transform install-hook`) >>>";
pub(crate) const HOOK_GUARD_END: &str = "# <<< dodot transform check --strict <<<";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallHookOutcome {
Created,
Appended,
AlreadyInstalled,
Updated,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallHookResult {
pub outcome: InstallHookOutcome,
pub hook_path: String,
pub hook_display_path: String,
pub command_line: String,
}
pub fn install_hook(ctx: &ExecutionContext) -> Result<InstallHookResult> {
let dotfiles_root = ctx.paths.dotfiles_root();
let git_dir = dotfiles_root.join(".git");
if !ctx.fs.is_dir(&git_dir) {
return Err(crate::DodotError::Other(format!(
"no .git directory at {}; pre-commit hooks only apply to git working \
trees. Run `git init` in {} first.",
git_dir.display(),
dotfiles_root.display(),
)));
}
let hooks_dir = git_dir.join("hooks");
let hook_path = hooks_dir.join("pre-commit");
let block = managed_block();
let outcome = if ctx.fs.exists(&hook_path) {
let existing = ctx.fs.read_to_string(&hook_path)?;
if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
let current_block = &existing[start_byte..end_byte];
if current_block == block {
InstallHookOutcome::AlreadyInstalled
} else {
let mut new_content = String::with_capacity(existing.len() + block.len());
new_content.push_str(&existing[..start_byte]);
new_content.push_str(&block);
new_content.push_str(&existing[end_byte..]);
ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
ctx.fs.set_permissions(&hook_path, 0o755)?;
InstallHookOutcome::Updated
}
} else {
let mut new_content = existing.clone();
if !new_content.ends_with('\n') {
new_content.push('\n');
}
if !new_content.ends_with("\n\n") {
new_content.push('\n');
}
new_content.push_str(&block);
ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
ctx.fs.set_permissions(&hook_path, 0o755)?;
InstallHookOutcome::Appended
}
} else {
ctx.fs.mkdir_all(&hooks_dir)?;
let mut new_content = String::from("#!/bin/sh\n\n");
new_content.push_str(&block);
ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
ctx.fs.set_permissions(&hook_path, 0o755)?;
InstallHookOutcome::Created
};
Ok(InstallHookResult {
outcome,
hook_path: hook_path.display().to_string(),
hook_display_path: render_path(&hook_path, ctx.paths.home_dir()),
command_line: HOOK_COMMAND.to_string(),
})
}
pub fn hook_is_installed(ctx: &ExecutionContext) -> Result<bool> {
let hook_path = ctx.paths.dotfiles_root().join(".git/hooks/pre-commit");
if !ctx.fs.exists(&hook_path) {
return Ok(false);
}
let existing = ctx.fs.read_to_string(&hook_path)?;
Ok(existing.contains(HOOK_GUARD_START))
}
pub fn managed_block() -> String {
format!(
"{guard_start}\n\
# Aborts the commit if any template-source has drift that needs review —\n\
# divergent deployed file or unresolved dodot-conflict markers. Remove\n\
# this block to opt out.\n\
{refresh}\n\
{check}\n\
{guard_end}\n",
guard_start = HOOK_GUARD_START,
guard_end = HOOK_GUARD_END,
refresh = HOOK_COMMAND_REFRESH,
check = HOOK_COMMAND_CHECK,
)
}
pub(crate) const HOOK_COMMAND_REFRESH: &str = "dodot refresh --quiet || exit 1";
pub(crate) const HOOK_COMMAND_CHECK: &str = "dodot transform check --strict || exit 1";
pub(crate) const HOOK_COMMAND: &str = "dodot refresh --quiet && dodot transform check --strict";
fn find_managed_block(text: &str) -> Option<(usize, usize)> {
let start = text.find(HOOK_GUARD_START)?;
let after_start = start + HOOK_GUARD_START.len();
let end_rel = text[after_start..].find(HOOK_GUARD_END)?;
let end_guard_start = after_start + end_rel;
let end_byte = end_guard_start + HOOK_GUARD_END.len();
let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
end_byte + 1
} else {
end_byte
};
Some((start, end_byte))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
use crate::config::ConfigManager;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::fs::Fs;
use crate::paths::Pather;
use std::sync::Arc;
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true,
provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
}
}
fn deploy_template(
env: &TempEnvironment,
pack: &str,
template_name: &str,
template_body: &str,
config_toml: &str,
) -> std::path::PathBuf {
let src_path = env.dotfiles_root.join(pack).join(template_name);
env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
env.fs
.write_file(&src_path, template_body.as_bytes())
.unwrap();
if !config_toml.is_empty() {
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
config_toml.as_bytes(),
)
.unwrap();
}
let ctx = make_ctx(env);
let _ = crate::commands::up::up(None, &ctx).unwrap();
src_path
}
fn deployed_path(env: &TempEnvironment, pack: &str, filename: &str) -> std::path::PathBuf {
env.paths
.data_dir()
.join("packs")
.join(pack)
.join("preprocessed")
.join(filename)
}
#[test]
fn empty_cache_yields_clean_no_findings() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert!(result.entries.is_empty());
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
}
#[test]
fn synced_files_report_synced_and_no_findings() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(matches!(result.entries[0].action, TransformAction::Synced));
assert!(!result.has_findings);
}
#[test]
fn output_changed_static_edit_patches_source() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(
matches!(result.entries[0].action, TransformAction::Patched),
"got: {:?}",
result.entries[0].action
);
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
let new_src = env.fs.read_to_string(&src_path).unwrap();
assert!(new_src.contains("port = 9999"), "src: {new_src:?}");
assert!(new_src.contains("name = {{ name }}"), "src: {new_src:?}");
}
#[test]
fn output_changed_pure_data_edit_yields_synced() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs.write_file(&deployed, b"name = Bob\n").unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(matches!(result.entries[0].action, TransformAction::Synced));
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn no_reverse_pattern_skips_reverse_merge() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\n\
name = \"Alice\"\n\
[preprocessor.template]\n\
no_reverse = [\"config.toml.tmpl\"]\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(
matches!(result.entries[0].action, TransformAction::Synced),
"no_reverse must short-circuit to Synced; got: {:?}",
result.entries[0].action
);
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn no_reverse_glob_pattern_skips_reverse_merge() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"foo.gen.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\n\
name = \"Alice\"\n\
[preprocessor.template]\n\
no_reverse = [\"*.gen.tmpl\"]\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "foo.gen");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(matches!(result.entries[0].action, TransformAction::Synced));
assert!(!result.has_findings);
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn dry_run_does_not_write_to_source() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let mut ctx = make_ctx(&env);
ctx.dry_run = true;
let result = check(&ctx, false).unwrap();
assert!(matches!(result.entries[0].action, TransformAction::Patched));
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn needs_rebaseline_when_tracked_render_is_empty_and_deployed_edited() {
let env = TempEnvironment::builder().build();
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
env.fs.write_file(&src_path, b"name = {{ name }}").unwrap();
let baseline = crate::preprocessing::baseline::Baseline::build(
&src_path,
b"name = Alice",
b"name = {{ name }}",
None, None,
);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
env.fs
.write_file(&deployed, b"name = Edited\nport = 9999")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(
matches!(result.entries[0].action, TransformAction::NeedsRebaseline),
"got: {:?}",
result.entries[0].action
);
assert!(
result.has_findings,
"NeedsRebaseline must count as a finding"
);
assert_eq!(result.exit_code(), 1);
let src_after = env.fs.read_to_string(&src_path).unwrap();
assert_eq!(src_after, "name = {{ name }}");
}
#[test]
fn missing_source_is_reported_with_finding() {
let env = TempEnvironment::builder().build();
let baseline = crate::preprocessing::baseline::Baseline::build(
&env.dotfiles_root.join("app/missing.toml.tmpl"),
b"rendered",
b"src",
Some(""),
None,
);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"missing.toml",
)
.unwrap();
let deployed = deployed_path(&env, "app", "missing.toml");
env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
env.fs.write_file(&deployed, b"rendered").unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert!(matches!(
result.entries[0].action,
TransformAction::MissingSource
));
assert!(result.has_findings);
}
#[test]
fn strict_mode_flags_unresolved_marker_in_source() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let dirty = format!(
"first\n{}\nbody\n{}\n",
crate::preprocessing::conflict::MARKER_START,
crate::preprocessing::conflict::MARKER_END,
);
env.fs.write_file(&src_path, dirty.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let lax = check(&ctx, false).unwrap();
assert!(lax.unresolved_markers.is_empty());
let strict = check(&ctx, true).unwrap();
assert_eq!(strict.unresolved_markers.len(), 1);
assert_eq!(strict.unresolved_markers[0].line_numbers, vec![2, 4]);
assert!(strict.has_findings);
assert_eq!(strict.exit_code(), 1);
}
#[test]
fn strict_mode_clean_repo_is_zero_findings() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = check(&ctx, true).unwrap();
assert!(result.unresolved_markers.is_empty());
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
}
#[test]
fn paths_are_rendered_relative_to_home_for_display() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
let entry = &result.entries[0];
assert!(
entry.source_path.starts_with("~/") || entry.deployed_path.starts_with("~/"),
"expected ~/-relative paths in report, got source={} deployed={}",
entry.source_path,
entry.deployed_path
);
}
#[test]
fn status_on_clean_repo_reports_one_synced_row() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert_eq!(result.entries.len(), 1);
assert_eq!(result.entries[0].state, "synced");
assert_eq!(result.synced_count, 1);
assert_eq!(result.diverged_count, 0);
assert_eq!(result.missing_count, 0);
}
#[test]
fn status_classifies_output_change() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert_eq!(result.entries[0].state, "output_changed");
assert_eq!(result.diverged_count, 1);
assert_eq!(result.synced_count, 0);
}
#[test]
fn status_does_not_mutate_anything() {
let env = TempEnvironment::builder().build();
let src = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let original_src = env.fs.read_to_string(&src).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let _ = status(&ctx).unwrap();
assert_eq!(env.fs.read_to_string(&src).unwrap(), original_src);
}
#[test]
fn status_empty_cache_yields_zero_counts() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert!(result.entries.is_empty());
assert_eq!(result.synced_count, 0);
assert_eq!(result.diverged_count, 0);
assert_eq!(result.missing_count, 0);
}
fn fake_git_dir(env: &TempEnvironment) {
env.fs
.mkdir_all(&env.dotfiles_root.join(".git/hooks"))
.unwrap();
}
#[test]
fn install_hook_creates_new_pre_commit_when_absent() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
assert!(!env.fs.exists(&hook_path));
let ctx = make_ctx(&env);
let result = install_hook(&ctx).unwrap();
assert!(matches!(result.outcome, InstallHookOutcome::Created));
assert!(env.fs.exists(&hook_path));
let body = env.fs.read_to_string(&hook_path).unwrap();
assert!(body.starts_with("#!/bin/sh\n"), "body: {body:?}");
assert!(body.contains(HOOK_GUARD_START), "body: {body:?}");
assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
assert!(body.contains(HOOK_GUARD_END), "body: {body:?}");
}
#[test]
fn install_hook_appends_to_existing_pre_commit() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
let existing = "#!/bin/sh\necho 'my pre-commit'\nexit 0\n";
env.fs.write_file(&hook_path, existing.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let result = install_hook(&ctx).unwrap();
assert!(matches!(result.outcome, InstallHookOutcome::Appended));
let body = env.fs.read_to_string(&hook_path).unwrap();
assert!(body.starts_with(existing), "user content lost: {body:?}");
assert!(body.contains(HOOK_GUARD_START));
assert!(body.contains(HOOK_COMMAND_REFRESH));
assert!(body.contains(HOOK_COMMAND_CHECK));
}
#[test]
fn install_hook_is_idempotent_on_second_call() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
let r1 = install_hook(&ctx).unwrap();
assert!(matches!(r1.outcome, InstallHookOutcome::Created));
let body_after_first = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
let r2 = install_hook(&ctx).unwrap();
assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
let body_after_second = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
assert_eq!(
body_after_first, body_after_second,
"body changed on second call"
);
assert_eq!(body_after_second.matches(HOOK_GUARD_START).count(), 1);
}
#[test]
fn install_hook_errors_if_no_git_dir() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let err = install_hook(&ctx).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("no .git directory"), "msg: {msg}");
assert!(msg.contains("git init"), "msg: {msg}");
}
#[test]
fn hook_is_installed_reports_correctly() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
assert!(!hook_is_installed(&ctx).unwrap());
install_hook(&ctx).unwrap();
assert!(hook_is_installed(&ctx).unwrap());
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
env.fs
.write_file(&hook_path, b"#!/bin/sh\necho hello\n")
.unwrap();
assert!(!hook_is_installed(&ctx).unwrap());
}
#[test]
fn install_hook_sets_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
install_hook(&ctx).unwrap();
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
let mode = std::fs::metadata(&hook_path).unwrap().permissions().mode();
assert!(
mode & 0o100 != 0,
"hook is not executable, mode = {:o}",
mode
);
}
#[test]
fn managed_block_is_self_contained_and_grep_detectable() {
let block = managed_block();
assert!(block.starts_with(HOOK_GUARD_START));
assert!(block.trim_end().ends_with(HOOK_GUARD_END));
assert!(block.contains(HOOK_COMMAND_REFRESH));
assert!(block.contains(HOOK_COMMAND_CHECK));
}
#[test]
fn install_hook_replaces_a_stale_managed_block() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let stale = format!(
"#!/bin/sh\n\
echo 'user-installed pre-commit step'\n\
\n\
{start}\n\
# Old-style block from R4. Still works, but doesn't run\n\
# `dodot refresh` first, so deployed-side edits between\n\
# commits aren't always picked up.\n\
dodot transform check --strict || exit 1\n\
{end}\n\
# User content after the block.\n\
echo 'trailing user step'\n",
start = HOOK_GUARD_START,
end = HOOK_GUARD_END,
);
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
env.fs.write_file(&hook_path, stale.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let result = install_hook(&ctx).unwrap();
assert!(matches!(result.outcome, InstallHookOutcome::Updated));
let body = env.fs.read_to_string(&hook_path).unwrap();
assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
assert!(body.contains("user-installed pre-commit step"));
assert!(body.contains("trailing user step"));
assert_eq!(body.matches(HOOK_GUARD_START).count(), 1);
assert_eq!(body.matches(HOOK_GUARD_END).count(), 1);
}
#[test]
fn install_hook_no_op_on_current_block() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
let r1 = install_hook(&ctx).unwrap();
assert!(matches!(r1.outcome, InstallHookOutcome::Created));
let body_after_first = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
let r2 = install_hook(&ctx).unwrap();
assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
let body_after_second = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
assert_eq!(body_after_first, body_after_second);
}
#[test]
fn find_managed_block_locates_byte_range() {
let block = managed_block();
let prefix = "before\n";
let suffix = "after\n";
let text = format!("{prefix}{block}{suffix}");
let (start, end) = find_managed_block(&text).expect("must find block");
assert_eq!(&text[start..end], block);
}
#[test]
fn find_managed_block_returns_none_when_absent() {
assert!(find_managed_block("nothing here").is_none());
let only_start = format!("{HOOK_GUARD_START}\nrandom content\n");
assert!(find_managed_block(&only_start).is_none());
}
}