Skip to main content

dodot_lib/commands/
refresh.rs

1//! `dodot refresh` — touch source mtimes when deployed bytes diverged.
2//!
3//! Walks the per-file baseline cache, hashes each deployed (datastore-
4//! side) file, and copies the deployed file's mtime onto the template
5//! source whenever the hashes differ. Why: git uses stat-cache mtimes
6//! to decide whether to re-read a working-tree file, so without this
7//! step a deployed-side edit never surfaces in `git status` (the
8//! source mtime hasn't changed → git uses the cached hash → no clean-
9//! filter invocation → no diff). Touching the source forces a re-read.
10//!
11//! See `docs/proposals/magic.lex` §"Update Trigger Bit". This command
12//! is the engine the Tier 2 shell alias (`alias git='dodot refresh
13//! --quiet && command git'`) and external file-watcher integrations
14//! call before delegating to git.
15//!
16//! # Modes
17//!
18//! - **default**: writes a short report to stdout (touched / clean
19//!   counts, per-file lines for touched entries).
20//! - **`--quiet`**: silent, exit 0. Intended for the shell alias so a
21//!   no-op refresh doesn't print on every git invocation.
22//! - **`--list-paths`**: prints absolute source paths that need a
23//!   touch (mtime not yet copied), one per line. Intended for editor
24//!   / file-watcher integrations that want to drive the touch
25//!   themselves; we don't write mtimes in this mode.
26//!
27//! Exit code: 0 in all healthy cases. Errors (real I/O failures only)
28//! propagate as `DodotError::Fs`.
29
30use serde::Serialize;
31
32use crate::packs::orchestration::ExecutionContext;
33use crate::preprocessing::baseline::hex_sha256;
34use crate::preprocessing::divergence::collect_baselines;
35use crate::Result;
36
37/// What `refresh` did to a single processed file.
38#[derive(Debug, Clone, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum RefreshAction {
41    /// Deployed file's hash matches the baseline; nothing to do.
42    Clean,
43    /// Source mtime was copied from the deployed file (default mode)
44    /// or would be (`--list-paths` mode).
45    Touched,
46    /// Deployed file is missing from the datastore (e.g. user removed
47    /// it). Reported but not actioned.
48    MissingDeployed,
49    /// Cached source path no longer exists on disk. Reported.
50    MissingSource,
51}
52
53/// One row in the refresh report.
54#[derive(Debug, Clone, Serialize)]
55pub struct RefreshEntry {
56    pub pack: String,
57    pub handler: String,
58    pub filename: String,
59    /// Absolute source path. The CLI renderer (and the JSON output)
60    /// both surface this verbatim — refresh entries are typically a
61    /// short list, and the absolute path is unambiguous when the
62    /// user wants to plug `--list-paths` output into a watcher.
63    pub source_path: String,
64    pub action: RefreshAction,
65}
66
67/// Aggregate result of a refresh invocation.
68#[derive(Debug, Clone, Serialize)]
69pub struct RefreshResult {
70    pub entries: Vec<RefreshEntry>,
71    /// True iff at least one entry was Touched. Drives the
72    /// `--list-paths` and report-mode rendering.
73    pub touched_any: bool,
74    /// Operating mode chosen by the caller, surfaced so the renderer
75    /// can pick the right template branch.
76    pub mode: RefreshMode,
77}
78
79/// Refresh invocation mode.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum RefreshMode {
83    /// Default: write mtimes, render a short report.
84    Report,
85    /// `--quiet`: write mtimes, render nothing.
86    Quiet,
87    /// `--list-paths`: do NOT write mtimes; render only the source
88    /// paths of divergent entries (one per line).
89    ListPaths,
90}
91
92/// Run `dodot refresh` in the given mode.
93///
94/// Walks every cached baseline. For each:
95///   - read the deployed bytes from `<data_dir>/packs/<pack>/<handler>/<filename>`
96///   - hash them; compare to `baseline.rendered_hash`
97///   - if equal → action `Clean`
98///   - if differ AND mode != ListPaths → copy deployed mtime onto source, action `Touched`
99///   - if differ AND mode == ListPaths → action `Touched` (no write; the source path will be printed)
100///   - if deployed is missing → action `MissingDeployed`
101///   - if source path is empty or missing → action `MissingSource`
102pub fn refresh(ctx: &ExecutionContext, mode: RefreshMode) -> Result<RefreshResult> {
103    let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
104    let mut entries = Vec::with_capacity(baselines.len());
105    let mut touched_any = false;
106
107    for (pack, handler, filename, baseline) in baselines {
108        let source_path = baseline.source_path.clone();
109        let deployed_path = ctx
110            .paths
111            .data_dir()
112            .join("packs")
113            .join(&pack)
114            .join(&handler)
115            .join(&filename);
116
117        let action = if source_path.as_os_str().is_empty() || !ctx.fs.exists(&source_path) {
118            RefreshAction::MissingSource
119        } else if !ctx.fs.exists(&deployed_path) {
120            RefreshAction::MissingDeployed
121        } else {
122            // Hash the deployed bytes. A read error here surfaces as a
123            // hard error rather than silently logging — refresh is a
124            // small command and we'd rather fail loudly than drop a
125            // sync that the user thinks succeeded.
126            let bytes = ctx.fs.read_file(&deployed_path)?;
127            if hex_sha256(&bytes) == baseline.rendered_hash {
128                RefreshAction::Clean
129            } else {
130                if mode != RefreshMode::ListPaths {
131                    let deployed_mtime = ctx.fs.modified(&deployed_path)?;
132                    let source_mtime = ctx.fs.modified(&source_path)?;
133                    // The whole point of refresh is to invalidate
134                    // git's stat-cache by changing the source mtime.
135                    // If the deployed mtime happens to equal the
136                    // current source mtime — possible on coarse-
137                    // resolution filesystems (FAT, HFS+ at 1s
138                    // granularity) or when a user edits and refreshes
139                    // within the same second — copying it would be a
140                    // no-op and git would not re-read the file. Bump
141                    // by 1s in that case so the mtime strictly
142                    // changes. We don't care that the source mtime
143                    // ends up "ahead of" the deployed mtime; what
144                    // matters is that it differs from the cached
145                    // value git has.
146                    let target = if deployed_mtime == source_mtime {
147                        deployed_mtime + std::time::Duration::from_secs(1)
148                    } else {
149                        deployed_mtime
150                    };
151                    ctx.fs.set_modified(&source_path, target)?;
152                }
153                touched_any = true;
154                RefreshAction::Touched
155            }
156        };
157
158        entries.push(RefreshEntry {
159            pack,
160            handler,
161            filename,
162            source_path: source_path.display().to_string(),
163            action,
164        });
165    }
166
167    Ok(RefreshResult {
168        entries,
169        touched_any,
170        mode,
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::fs::Fs;
178    use crate::paths::Pather;
179    use crate::preprocessing::baseline::Baseline;
180    use crate::testing::TempEnvironment;
181
182    fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
183        use crate::config::ConfigManager;
184        use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
185        use std::sync::Arc;
186
187        struct NoopRunner;
188        impl CommandRunner for NoopRunner {
189            fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
190                Ok(CommandOutput {
191                    exit_code: 0,
192                    stdout: String::new(),
193                    stderr: String::new(),
194                })
195            }
196        }
197        let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
198        let datastore = Arc::new(FilesystemDataStore::new(
199            env.fs.clone(),
200            env.paths.clone(),
201            runner.clone(),
202        ));
203        let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
204        ExecutionContext {
205            fs: env.fs.clone() as Arc<dyn Fs>,
206            datastore,
207            paths: env.paths.clone() as Arc<dyn Pather>,
208            config_manager,
209            syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
210            command_runner: runner,
211            dry_run: false,
212            no_provision: true,
213            provision_rerun: false,
214            force: false,
215            view_mode: crate::commands::ViewMode::Full,
216            group_mode: crate::commands::GroupMode::Name,
217            verbose: false,
218        }
219    }
220
221    fn write_file(env: &TempEnvironment, path: &std::path::Path, body: &[u8]) {
222        env.fs.mkdir_all(path.parent().unwrap()).unwrap();
223        env.fs.write_file(path, body).unwrap();
224    }
225
226    /// Stage a baseline + matching pack source + matching deployed
227    /// file. Returns the absolute source and deployed paths so the
228    /// test can edit either side.
229    fn stage_one(
230        env: &TempEnvironment,
231        pack: &str,
232        template_name: &str,
233        rendered: &[u8],
234        source: &[u8],
235    ) -> (std::path::PathBuf, std::path::PathBuf) {
236        let src = env.dotfiles_root.join(pack).join(template_name);
237        write_file(env, &src, source);
238        let stripped = template_name.strip_suffix(".tmpl").unwrap_or(template_name);
239        let deployed = env
240            .paths
241            .data_dir()
242            .join("packs")
243            .join(pack)
244            .join("preprocessed")
245            .join(stripped);
246        write_file(env, &deployed, rendered);
247        let baseline = Baseline::build(&src, rendered, source, Some(""), None);
248        baseline
249            .write(
250                env.fs.as_ref(),
251                env.paths.as_ref(),
252                pack,
253                "preprocessed",
254                stripped,
255            )
256            .unwrap();
257        (src, deployed)
258    }
259
260    #[test]
261    fn empty_cache_yields_empty_report() {
262        let env = TempEnvironment::builder().build();
263        let ctx = make_ctx(&env);
264        let r = refresh(&ctx, RefreshMode::Report).unwrap();
265        assert!(r.entries.is_empty());
266        assert!(!r.touched_any);
267    }
268
269    #[test]
270    fn clean_state_is_a_noop() {
271        // baseline + source + deployed all line up. No mtime touched.
272        let env = TempEnvironment::builder().build();
273        let (src, _) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
274        // Capture the source mtime before refresh; a no-op must not
275        // change it.
276        let before = env.fs.modified(&src).unwrap();
277
278        let ctx = make_ctx(&env);
279        let r = refresh(&ctx, RefreshMode::Report).unwrap();
280        assert_eq!(r.entries.len(), 1);
281        assert!(matches!(r.entries[0].action, RefreshAction::Clean));
282        assert!(!r.touched_any);
283        assert_eq!(env.fs.modified(&src).unwrap(), before);
284    }
285
286    #[test]
287    fn divergent_deployed_touches_source_mtime() {
288        // The core scenario: user edits the deployed file → source
289        // mtime gets bumped to match.
290        let env = TempEnvironment::builder().build();
291        let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
292
293        // Edit the deployed file to a divergent value AFTER the
294        // baseline. Sleep briefly so the deployed mtime is strictly
295        // later than the source's.
296        std::thread::sleep(std::time::Duration::from_millis(20));
297        env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
298        let deployed_mtime = env.fs.modified(&deployed).unwrap();
299
300        let ctx = make_ctx(&env);
301        let r = refresh(&ctx, RefreshMode::Report).unwrap();
302        assert_eq!(r.entries.len(), 1);
303        assert!(matches!(r.entries[0].action, RefreshAction::Touched));
304        assert!(r.touched_any);
305
306        // Source mtime now equals the deployed mtime.
307        let new_src_mtime = env.fs.modified(&src).unwrap();
308        assert_eq!(new_src_mtime, deployed_mtime);
309    }
310
311    #[test]
312    fn list_paths_mode_does_not_write_mtimes() {
313        // `--list-paths` reports divergent sources but never touches.
314        // Editor / watcher integrations want to drive the touch
315        // themselves so they can sequence it correctly with their own
316        // build steps.
317        let env = TempEnvironment::builder().build();
318        let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
319
320        let before_src = env.fs.modified(&src).unwrap();
321        std::thread::sleep(std::time::Duration::from_millis(20));
322        env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
323
324        let ctx = make_ctx(&env);
325        let r = refresh(&ctx, RefreshMode::ListPaths).unwrap();
326        assert_eq!(r.entries.len(), 1);
327        assert!(matches!(r.entries[0].action, RefreshAction::Touched));
328        assert!(r.touched_any);
329
330        // mtime unchanged.
331        assert_eq!(env.fs.modified(&src).unwrap(), before_src);
332    }
333
334    #[test]
335    fn quiet_mode_still_writes_mtimes() {
336        // `--quiet` is just an output-suppression flag; the work
337        // itself happens. The shell alias depends on this.
338        let env = TempEnvironment::builder().build();
339        let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
340
341        std::thread::sleep(std::time::Duration::from_millis(20));
342        env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
343        let deployed_mtime = env.fs.modified(&deployed).unwrap();
344
345        let ctx = make_ctx(&env);
346        let r = refresh(&ctx, RefreshMode::Quiet).unwrap();
347        assert!(matches!(r.entries[0].action, RefreshAction::Touched));
348        assert_eq!(env.fs.modified(&src).unwrap(), deployed_mtime);
349    }
350
351    #[test]
352    fn missing_source_is_reported_not_an_error() {
353        // The cached source path no longer exists (user renamed /
354        // removed the .tmpl). Refresh keeps going; the entry is
355        // surfaced so the user knows the cache is stale.
356        let env = TempEnvironment::builder().build();
357        // Stage a baseline whose source path doesn't exist on disk.
358        let baseline = Baseline::build(
359            &env.dotfiles_root.join("app/missing.toml.tmpl"),
360            b"rendered",
361            b"src",
362            Some(""),
363            None,
364        );
365        baseline
366            .write(
367                env.fs.as_ref(),
368                env.paths.as_ref(),
369                "app",
370                "preprocessed",
371                "missing.toml",
372            )
373            .unwrap();
374        // Deployed file exists.
375        let deployed = env
376            .paths
377            .data_dir()
378            .join("packs/app/preprocessed/missing.toml");
379        write_file(&env, &deployed, b"rendered");
380
381        let ctx = make_ctx(&env);
382        let r = refresh(&ctx, RefreshMode::Report).unwrap();
383        assert_eq!(r.entries.len(), 1);
384        assert!(matches!(r.entries[0].action, RefreshAction::MissingSource));
385        assert!(!r.touched_any);
386    }
387
388    #[test]
389    fn missing_deployed_is_reported_not_an_error() {
390        // The deployed file is gone; refresh has nothing to compare
391        // against. Surface as MissingDeployed.
392        let env = TempEnvironment::builder().build();
393        let src = env.dotfiles_root.join("app/cfg.toml.tmpl");
394        write_file(&env, &src, b"src");
395        let baseline = Baseline::build(&src, b"rendered", b"src", Some(""), None);
396        baseline
397            .write(
398                env.fs.as_ref(),
399                env.paths.as_ref(),
400                "app",
401                "preprocessed",
402                "cfg.toml",
403            )
404            .unwrap();
405        // Don't lay down the deployed file.
406
407        let ctx = make_ctx(&env);
408        let r = refresh(&ctx, RefreshMode::Report).unwrap();
409        assert!(matches!(
410            r.entries[0].action,
411            RefreshAction::MissingDeployed
412        ));
413        assert!(!r.touched_any);
414    }
415
416    #[test]
417    fn pure_data_edit_is_still_treated_as_divergent() {
418        // Edge case: the user edited only a variable's *value* in the
419        // deployed file. The deployed bytes diverge from the
420        // baseline, so refresh touches the source. The clean filter
421        // (R6, when installed) will then re-evaluate and decide
422        // whether the change is worth a template-space diff. Refresh
423        // itself is intentionally a coarse hash compare — it errs on
424        // the side of triggering the filter rather than missing a
425        // real edit.
426        let env = TempEnvironment::builder().build();
427        let (_src, deployed) = stage_one(
428            &env,
429            "app",
430            "greet.tmpl",
431            b"hello Alice",
432            b"hello {{ name }}",
433        );
434        std::thread::sleep(std::time::Duration::from_millis(20));
435        env.fs.write_file(&deployed, b"hello Bob").unwrap();
436
437        let ctx = make_ctx(&env);
438        let r = refresh(&ctx, RefreshMode::Report).unwrap();
439        assert!(matches!(r.entries[0].action, RefreshAction::Touched));
440        assert!(r.touched_any);
441    }
442
443    #[test]
444    fn divergent_with_equal_mtimes_still_bumps_source() {
445        // Edge case from PR review: if the deployed mtime happens to
446        // equal the source mtime (coarse FS, rapid edits within the
447        // same second), `set_modified(source, deployed_mtime)` would
448        // be a no-op — git's stat-cache wouldn't invalidate, and
449        // refresh would silently fail at its core purpose. We bump
450        // by 1s in that case so the source mtime *strictly* changes.
451        let env = TempEnvironment::builder().build();
452        let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
453
454        // Force the deployed mtime to exactly match the current
455        // source mtime, then mutate the deployed bytes so refresh
456        // sees a divergence to act on.
457        let pinned = env.fs.modified(&src).unwrap();
458        env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
459        env.fs.set_modified(&deployed, pinned).unwrap();
460        assert_eq!(env.fs.modified(&deployed).unwrap(), pinned);
461
462        let ctx = make_ctx(&env);
463        let r = refresh(&ctx, RefreshMode::Report).unwrap();
464        assert!(matches!(r.entries[0].action, RefreshAction::Touched));
465
466        // Source mtime must STRICTLY exceed the original (no-op
467        // behaviour would leave it unchanged).
468        let after = env.fs.modified(&src).unwrap();
469        assert!(
470            after > pinned,
471            "source mtime should strictly increase even when deployed mtime equals source mtime"
472        );
473    }
474
475    #[test]
476    fn entries_are_sorted_by_pack_handler_filename() {
477        // Stable display order — the underlying walker is sorted, and
478        // refresh inherits that. Pin it so callers can rely on
479        // deterministic output.
480        let env = TempEnvironment::builder().build();
481        for (pack, name) in [
482            ("zebra", "z.tmpl"),
483            ("alpha", "b.tmpl"),
484            ("alpha", "a.tmpl"),
485        ] {
486            stage_one(&env, pack, name, b"rendered", b"src");
487        }
488        let ctx = make_ctx(&env);
489        let r = refresh(&ctx, RefreshMode::Report).unwrap();
490        let order: Vec<_> = r
491            .entries
492            .iter()
493            .map(|e| (e.pack.clone(), e.filename.clone()))
494            .collect();
495        assert_eq!(
496            order,
497            vec![
498                ("alpha".into(), "a".into()),
499                ("alpha".into(), "b".into()),
500                ("zebra".into(), "z".into()),
501            ]
502        );
503    }
504}