Skip to main content

dodot_lib/commands/
transform.rs

1//! `dodot transform check` — propagate deployed-file edits back to
2//! template sources via the cached baseline + reverse-merge pipeline.
3//!
4//! Reads every per-file baseline under `<cache_dir>/preprocessor/`,
5//! classifies each entry against the 4-state matrix from
6//! `docs/proposals/preprocessing-pipeline.lex` §6.1, and acts on each
7//! state:
8//!
9//! | state            | action                                              |
10//! |------------------|-----------------------------------------------------|
11//! | `Synced`         | nothing (no divergence)                             |
12//! | `InputChanged`   | nothing (next `dodot up` re-renders)                |
13//! | `OutputChanged`  | reverse-merge into source; clean diff → write back  |
14//! | `BothChanged`    | reverse-merge into source; conflict → report       |
15//! | `MissingSource`  | report only (cache stale; next `up` will refresh)   |
16//! | `MissingDeployed`| report only (deployed file gone; manual recovery)   |
17//!
18//! For `OutputChanged` and `BothChanged`, the call into burgertocow
19//! returns either a clean unified diff (which is applied to the source
20//! file via `diffy`) or a conflict block (which is *not* written —
21//! instead surfaced in the report so the user resolves it manually).
22//! The intent: `transform check` only mutates source files when the
23//! reverse-merge is unambiguous, and surfaces every other case for
24//! human review.
25//!
26//! # Strict mode
27//!
28//! `check(ctx, strict=true)` is the form used by the pre-commit hook
29//! (R4). On top of the matrix work above, it scans every source file
30//! for unresolved [`crate::preprocessing::conflict`] markers — if any
31//! are found, the result reports them and the command exits non-zero
32//! so a commit is blocked until the user resolves them.
33
34use serde::Serialize;
35
36use crate::packs::orchestration::ExecutionContext;
37use crate::preprocessing::conflict::find_unresolved_marker_lines;
38use crate::preprocessing::divergence::{
39    classify_one, collect_baselines, DivergenceReport, DivergenceState,
40};
41use crate::preprocessing::no_reverse::is_no_reverse;
42use crate::preprocessing::reverse_merge::{reverse_merge, ReverseMergeOutcome};
43use crate::Result;
44
45/// What `transform check` did to a single processed file.
46#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum TransformAction {
49    /// Source and deployed match the baseline — no action.
50    Synced,
51    /// Source has been edited; next `dodot up` will re-render.
52    InputChanged,
53    /// The reverse-merge produced a clean unified diff and the source
54    /// file was patched in place.
55    Patched,
56    /// The reverse-merge surfaced a conflict block; the source file is
57    /// left untouched. The user resolves it manually.
58    Conflict,
59    /// Reverse-merge declined to act (e.g. cached `tracked_render` was
60    /// empty — typically a v1 baseline written before this field
61    /// existed). Re-run `dodot up` to refresh the baseline.
62    NeedsRebaseline,
63    /// The cached source path no longer exists on disk.
64    MissingSource,
65    /// The deployed file is gone from the datastore.
66    MissingDeployed,
67}
68
69/// One row in the transform-check report.
70#[derive(Debug, Clone, Serialize)]
71pub struct TransformCheckEntry {
72    pub pack: String,
73    pub handler: String,
74    pub filename: String,
75    pub source_path: String,
76    pub deployed_path: String,
77    pub action: TransformAction,
78    /// For `Conflict`: the burgertocow-emitted block, ready for the
79    /// CLI layer to print. Empty for other actions.
80    #[serde(default, skip_serializing_if = "String::is_empty")]
81    pub conflict_block: String,
82}
83
84/// One unresolved-marker hit found in `--strict` mode. Path-and-line
85/// granularity, identical in shape to what the pipeline gate reports.
86#[derive(Debug, Clone, Serialize)]
87pub struct UnresolvedMarkerEntry {
88    pub source_path: String,
89    pub line_numbers: Vec<usize>,
90}
91
92/// Aggregate outcome of a `transform check` invocation.
93#[derive(Debug, Clone, Serialize)]
94pub struct TransformCheckResult {
95    pub entries: Vec<TransformCheckEntry>,
96    /// Populated only when `strict = true` and at least one source
97    /// carries unresolved dodot-conflict markers.
98    pub unresolved_markers: Vec<UnresolvedMarkerEntry>,
99    /// True iff at least one entry has a non-clean state that should
100    /// make the command exit non-zero (Conflict, NeedsRebaseline,
101    /// MissingSource, MissingDeployed) or `--strict` found unresolved
102    /// markers. CLI uses this to decide the process exit code.
103    ///
104    /// `Patched` does *not* set this — an unambiguous reverse-merge is
105    /// the auto-merge happy path: burgertocow + diffy produced a clean
106    /// unified patch with no markers, the source has been rewritten
107    /// to match, and there's nothing for the user to review. The
108    /// pre-commit hook lets the original `git commit` proceed; the
109    /// patched source surfaces as modified on the next `git status`,
110    /// at which point the user `git add`s and commits a follow-up
111    /// (or amends) if they want a clean history. Issue #113 walks
112    /// through the rationale.
113    pub has_findings: bool,
114    pub strict: bool,
115}
116
117impl TransformCheckResult {
118    /// Process exit code per the spec: 0 if everything is clean, 1
119    /// otherwise. Strict-mode unresolved markers also flip this to 1.
120    pub fn exit_code(&self) -> i32 {
121        if self.has_findings {
122            1
123        } else {
124            0
125        }
126    }
127}
128
129/// One row in `dodot transform status`'s passive report.
130///
131/// Mirrors `TransformCheckEntry` but without any of the action /
132/// conflict-block fields — `status` is a read-only inspection;
133/// `check` is the action layer.
134#[derive(Debug, Clone, Serialize)]
135pub struct TransformStatusEntry {
136    pub pack: String,
137    pub handler: String,
138    pub filename: String,
139    pub source_path: String,
140    pub deployed_path: String,
141    /// Mirror of `DivergenceState`, serialised as snake_case so the
142    /// template branches and JSON consumers see the same shape they
143    /// see in `transform check`.
144    #[serde(rename = "state")]
145    pub state: String,
146    /// References this file resolved through `secret(...)` on its
147    /// last successful render. Populated from
148    /// `<baseline>.secret.json` (per `secrets.lex` §3.3); empty
149    /// when the file has no sidecar (which is also the common
150    /// case for templates that don't use secrets, and for
151    /// pre-Phase-S1 baselines that pre-date sidecar tracking).
152    /// Phase S5 surfaces this in the rendered status so users can
153    /// see *which* secret references each baseline depends on
154    /// without re-rendering. JSON consumers see the same field.
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub secret_references: Vec<String>,
157}
158
159/// Aggregate result of `dodot transform status` — one row per
160/// cached baseline, plus a few rollup counters for the renderer.
161#[derive(Debug, Clone, Serialize)]
162pub struct TransformStatusResult {
163    pub entries: Vec<TransformStatusEntry>,
164    pub synced_count: usize,
165    pub diverged_count: usize,
166    pub missing_count: usize,
167}
168
169/// Run `dodot transform status` — read-only view of the baseline
170/// cache. Walks every cached entry and reports its state without
171/// running the reverse-merge engine, writing source files, or doing
172/// anything else that mutates state. Useful as a "what's currently
173/// out of sync?" check before deciding whether to run `dodot transform
174/// check`. Always exits 0 — even a fully-diverged repo isn't a
175/// failure here, just information.
176pub fn status(ctx: &ExecutionContext) -> Result<TransformStatusResult> {
177    use crate::preprocessing::divergence::{collect_divergences, DivergenceState};
178    let reports = collect_divergences(ctx.fs.as_ref(), ctx.paths.as_ref())?;
179    let mut synced_count = 0usize;
180    let mut diverged_count = 0usize;
181    let mut missing_count = 0usize;
182    let entries: Vec<TransformStatusEntry> = reports
183        .into_iter()
184        .map(|r| {
185            let state_str = match r.state {
186                DivergenceState::Synced => {
187                    synced_count += 1;
188                    "synced"
189                }
190                DivergenceState::InputChanged => {
191                    diverged_count += 1;
192                    "input_changed"
193                }
194                DivergenceState::OutputChanged => {
195                    diverged_count += 1;
196                    "output_changed"
197                }
198                DivergenceState::BothChanged => {
199                    diverged_count += 1;
200                    "both_changed"
201                }
202                DivergenceState::MissingSource => {
203                    missing_count += 1;
204                    "missing_source"
205                }
206                DivergenceState::MissingDeployed => {
207                    missing_count += 1;
208                    "missing_deployed"
209                }
210            };
211            // Sidecar reads are best-effort: a parse error
212            // shouldn't fail the whole status report, just leave
213            // this row's secret_references empty. The user can
214            // re-render to fix the sidecar via `dodot up
215            // --force` separately.
216            let secret_references = crate::preprocessing::baseline::SecretsSidecar::load(
217                ctx.fs.as_ref(),
218                ctx.paths.as_ref(),
219                &r.pack,
220                &r.handler,
221                &r.filename,
222            )
223            .ok()
224            .flatten()
225            .map(|s| {
226                s.secret_line_ranges
227                    .into_iter()
228                    .map(|range| range.reference)
229                    .collect::<Vec<_>>()
230            })
231            .unwrap_or_default();
232            TransformStatusEntry {
233                pack: r.pack,
234                handler: r.handler,
235                filename: r.filename,
236                source_path: render_path(&r.source_path, ctx.paths.home_dir()),
237                deployed_path: render_path(&r.deployed_path, ctx.paths.home_dir()),
238                state: state_str.to_string(),
239                secret_references,
240            }
241        })
242        .collect();
243    Ok(TransformStatusResult {
244        entries,
245        synced_count,
246        diverged_count,
247        missing_count,
248    })
249}
250
251/// Run `dodot transform check`. See module docs for the matrix.
252pub fn check(ctx: &ExecutionContext, strict: bool) -> Result<TransformCheckResult> {
253    let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
254    let mut entries: Vec<TransformCheckEntry> = Vec::with_capacity(baselines.len());
255    let mut has_findings = false;
256    // Memoise no_reverse patterns by pack within this check
257    // invocation. ConfigManager already caches resolved configs by
258    // absolute path, but each lookup still allocates and clones the
259    // Vec — for repos with many baselines per pack, that's wasted
260    // work. The map keeps the inner work to a single lookup per pack.
261    let mut no_reverse_cache: std::collections::HashMap<String, Vec<String>> =
262        std::collections::HashMap::new();
263
264    for (pack, handler, filename, baseline) in baselines {
265        let report = classify_one(
266            ctx.fs.as_ref(),
267            ctx.paths.as_ref(),
268            &pack,
269            &handler,
270            &filename,
271            &baseline,
272        );
273        // Per-pack [preprocessor.template] no_reverse opt-out: when a
274        // file matches, we treat it as Synced regardless of which
275        // divergence state the matrix reports. This keeps the file
276        // out of the reverse-merge engine (which can produce more
277        // conflict markers than usable diffs on mostly-dynamic
278        // templates) while leaving `dodot transform status` alone —
279        // status still surfaces the underlying state for visibility.
280        let no_reverse_patterns = no_reverse_cache
281            .entry(pack.clone())
282            .or_insert_with(|| pack_no_reverse_patterns(ctx, &pack));
283        let no_reverse = is_no_reverse(&report.source_path, no_reverse_patterns);
284        let action = match report.state {
285            DivergenceState::Synced => TransformAction::Synced,
286            DivergenceState::InputChanged => TransformAction::InputChanged,
287            DivergenceState::MissingSource => {
288                has_findings = true;
289                TransformAction::MissingSource
290            }
291            DivergenceState::MissingDeployed => {
292                has_findings = true;
293                TransformAction::MissingDeployed
294            }
295            DivergenceState::OutputChanged | DivergenceState::BothChanged if no_reverse => {
296                // Opted out — leave source untouched, surface as
297                // Synced. The user has explicitly chosen "detect
298                // divergence but don't auto-merge"; `transform
299                // status` still shows the real state.
300                TransformAction::Synced
301            }
302            DivergenceState::OutputChanged | DivergenceState::BothChanged => {
303                // Forward-compat short-circuit: a baseline written
304                // before the tracked-render field existed (or by a
305                // future preprocessor that opts into reverse-merge
306                // without producing a marker stream) has nothing for
307                // burgertocow to chew on. Surface as NeedsRebaseline
308                // — a finding in its own right — rather than masking
309                // it as Synced via reverse_merge's Unchanged fallback.
310                // Without this branch, an OutputChanged file with an
311                // empty tracked_render would silently report "no
312                // divergence" and the user would never know.
313                if baseline.tracked_render.is_empty() {
314                    has_findings = true;
315                    TransformAction::NeedsRebaseline
316                } else {
317                    // Run the reverse-merge engine. Unchanged → variable-
318                    // only edit, no action. Patched → write back to source.
319                    // Conflict → report the block, leave source alone.
320                    let template_src = ctx.fs.read_to_string(&report.source_path)?;
321                    let deployed = ctx.fs.read_to_string(&report.deployed_path)?;
322                    // Load the per-render secrets sidecar so the
323                    // reverse-merge masks lines whose source-of-truth
324                    // is a vault, not the deployed bytes. Absence of
325                    // the sidecar = empty mask = byte-identical to
326                    // pre-Phase-S2 behavior. See secrets.lex §3.3 and
327                    // burgertocow#13.
328                    let secret_ranges = crate::preprocessing::baseline::SecretsSidecar::load(
329                        ctx.fs.as_ref(),
330                        ctx.paths.as_ref(),
331                        &pack,
332                        &handler,
333                        &filename,
334                    )?
335                    .map(|s| s.secret_line_ranges)
336                    .unwrap_or_default();
337                    match reverse_merge(
338                        &template_src,
339                        &baseline.tracked_render,
340                        &deployed,
341                        &secret_ranges,
342                    )? {
343                        ReverseMergeOutcome::Unchanged => TransformAction::Synced,
344                        ReverseMergeOutcome::Patched(patched) => {
345                            if !ctx.dry_run {
346                                ctx.fs.write_file(&report.source_path, patched.as_bytes())?;
347                            }
348                            // `Patched` is the auto-merge happy path:
349                            // burgertocow + diffy produced an
350                            // unambiguous unified patch, the source
351                            // is now in sync with the user's edit.
352                            // Nothing for the user to review →
353                            // `has_findings` stays false. The patched
354                            // source surfaces as modified on the next
355                            // `git status` for a follow-up commit.
356                            // See #113.
357                            TransformAction::Patched
358                        }
359                        ReverseMergeOutcome::Conflict(block) => {
360                            has_findings = true;
361                            return_conflict_entry(
362                                &mut entries,
363                                report,
364                                block,
365                                ctx.paths.home_dir(),
366                            );
367                            continue;
368                        }
369                    }
370                }
371            }
372        };
373
374        entries.push(make_entry(report, action, ctx.paths.home_dir()));
375    }
376
377    let mut unresolved_markers = Vec::new();
378    if strict {
379        // Re-walk the cache, scanning each source for dodot-conflict
380        // markers. Any hit blocks a commit (when this is run from the
381        // pre-commit hook). We re-walk rather than reusing the loop
382        // above because the loop may have skipped entries via
383        // MissingSource / continue paths.
384        let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
385        for (_pack, _handler, _filename, baseline) in baselines {
386            if baseline.source_path.as_os_str().is_empty() || !ctx.fs.exists(&baseline.source_path)
387            {
388                continue;
389            }
390            let bytes = ctx.fs.read_file(&baseline.source_path)?;
391            let content = String::from_utf8_lossy(&bytes);
392            let lines = find_unresolved_marker_lines(&content);
393            if !lines.is_empty() {
394                has_findings = true;
395                unresolved_markers.push(UnresolvedMarkerEntry {
396                    source_path: render_path(&baseline.source_path, ctx.paths.home_dir()),
397                    line_numbers: lines.iter().map(|(n, _)| *n).collect(),
398                });
399            }
400        }
401    }
402
403    Ok(TransformCheckResult {
404        entries,
405        unresolved_markers,
406        has_findings,
407        strict,
408    })
409}
410
411fn make_entry(
412    report: DivergenceReport,
413    action: TransformAction,
414    home: &std::path::Path,
415) -> TransformCheckEntry {
416    TransformCheckEntry {
417        pack: report.pack,
418        handler: report.handler,
419        filename: report.filename,
420        source_path: render_path(&report.source_path, home),
421        deployed_path: render_path(&report.deployed_path, home),
422        action,
423        conflict_block: String::new(),
424    }
425}
426
427fn return_conflict_entry(
428    entries: &mut Vec<TransformCheckEntry>,
429    report: DivergenceReport,
430    block: String,
431    home: &std::path::Path,
432) {
433    entries.push(TransformCheckEntry {
434        pack: report.pack,
435        handler: report.handler,
436        filename: report.filename,
437        source_path: render_path(&report.source_path, home),
438        deployed_path: render_path(&report.deployed_path, home),
439        action: TransformAction::Conflict,
440        conflict_block: block,
441    });
442}
443
444fn render_path(p: &std::path::Path, home: &std::path::Path) -> String {
445    if let Ok(rel) = p.strip_prefix(home) {
446        format!("~/{}", rel.display())
447    } else {
448        p.display().to_string()
449    }
450}
451
452/// Resolve `[preprocessor.template] no_reverse` for the given pack.
453/// Honours the root → pack config inheritance. Returns an empty list
454/// on any config-loading hiccup (the user shouldn't lose `transform
455/// check` over a malformed pack `.dodot.toml` — the next `dodot up`
456/// will surface the actual config error).
457fn pack_no_reverse_patterns(ctx: &ExecutionContext, pack: &str) -> Vec<String> {
458    let pack_path = ctx.paths.dotfiles_root().join(pack);
459    match ctx.config_manager.config_for_pack(&pack_path) {
460        Ok(cfg) => cfg.preprocessor.template.no_reverse.clone(),
461        Err(_) => Vec::new(),
462    }
463}
464
465// ── install-hook ────────────────────────────────────────────────
466
467/// The guard line that opens our managed block in `.git/hooks/pre-commit`.
468/// Detection of this string is what makes [`install_hook`] idempotent.
469pub(crate) const HOOK_GUARD_START: &str =
470    "# >>> dodot transform check --strict (managed by `dodot transform install-hook`) >>>";
471
472/// The guard line that closes our managed block. Paired with
473/// [`HOOK_GUARD_START`].
474pub(crate) const HOOK_GUARD_END: &str = "# <<< dodot transform check --strict <<<";
475
476/// Outcome of `dodot transform install-hook`.
477#[derive(Debug, Clone, Serialize)]
478#[serde(rename_all = "snake_case")]
479pub enum InstallHookOutcome {
480    /// Hook file did not exist; we created it with shebang + our block.
481    Created,
482    /// Hook file existed; we appended our block to it. Existing content
483    /// is preserved.
484    Appended,
485    /// Hook was already installed and matches the current managed
486    /// block exactly — no change.
487    AlreadyInstalled,
488    /// Hook was installed but the managed block was an older version
489    /// (e.g. didn't yet call `dodot refresh`). We replaced the
490    /// outdated block in place. Existing non-managed content in the
491    /// hook file is preserved.
492    Updated,
493}
494
495/// Result returned by [`install_hook`]. Renders through the
496/// `transform-install-hook.jinja` template; CLI exits 0 in all three
497/// outcomes (every state is a success).
498#[derive(Debug, Clone, Serialize)]
499pub struct InstallHookResult {
500    pub outcome: InstallHookOutcome,
501    /// Absolute path of the hook file that was written or inspected.
502    pub hook_path: String,
503    /// Path of the hook rendered relative to `$HOME` for display.
504    pub hook_display_path: String,
505    /// The exact line the hook will execute on each commit. Surfaced
506    /// so the user can see what `--strict` looks like in their hook.
507    pub command_line: String,
508}
509
510/// Install (or detect-already-installed) the dodot pre-commit hook
511/// that runs `dodot transform check --strict`.
512///
513/// # Behavior
514///
515/// - If `<dotfiles_root>/.git/hooks/pre-commit` does not exist:
516///   create it with `#!/bin/sh` + our guarded block, mode `0o755`.
517/// - If it exists and already contains [`HOOK_GUARD_START`]:
518///   no-op, return [`InstallHookOutcome::AlreadyInstalled`].
519/// - If it exists without our guard: append our block (preserving
520///   existing content), ensure executable bit is set.
521///
522/// # Errors
523///
524/// Returns an error if `<dotfiles_root>/.git` doesn't exist (the
525/// dotfiles repo isn't a git working tree). The hook only makes
526/// sense in a git context.
527pub fn install_hook(ctx: &ExecutionContext) -> Result<InstallHookResult> {
528    let dotfiles_root = ctx.paths.dotfiles_root();
529    let git_dir = dotfiles_root.join(".git");
530    if !ctx.fs.is_dir(&git_dir) {
531        return Err(crate::DodotError::Other(format!(
532            "no .git directory at {}; pre-commit hooks only apply to git working \
533             trees. Run `git init` in {} first.",
534            git_dir.display(),
535            dotfiles_root.display(),
536        )));
537    }
538
539    let hooks_dir = git_dir.join("hooks");
540    let hook_path = hooks_dir.join("pre-commit");
541
542    let block = managed_block();
543
544    let outcome = if ctx.fs.exists(&hook_path) {
545        let existing = ctx.fs.read_to_string(&hook_path)?;
546        if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
547            // A managed block exists. Decide whether it matches the
548            // current `block` exactly (no-op) or is stale and needs
549            // replacing.
550            let current_block = &existing[start_byte..end_byte];
551            if current_block == block {
552                InstallHookOutcome::AlreadyInstalled
553            } else {
554                // Stale block — rewrite it in place. Anything outside
555                // the marker pair is preserved.
556                let mut new_content = String::with_capacity(existing.len() + block.len());
557                new_content.push_str(&existing[..start_byte]);
558                new_content.push_str(&block);
559                new_content.push_str(&existing[end_byte..]);
560                ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
561                ctx.fs.set_permissions(&hook_path, 0o755)?;
562                InstallHookOutcome::Updated
563            }
564        } else {
565            // No managed block at all — append. Preserves existing
566            // hook content (user-written or installed by another tool).
567            let mut new_content = existing.clone();
568            if !new_content.ends_with('\n') {
569                new_content.push('\n');
570            }
571            if !new_content.ends_with("\n\n") {
572                new_content.push('\n');
573            }
574            new_content.push_str(&block);
575            ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
576            ctx.fs.set_permissions(&hook_path, 0o755)?;
577            InstallHookOutcome::Appended
578        }
579    } else {
580        ctx.fs.mkdir_all(&hooks_dir)?;
581        let mut new_content = String::from("#!/bin/sh\n\n");
582        new_content.push_str(&block);
583        ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
584        ctx.fs.set_permissions(&hook_path, 0o755)?;
585        InstallHookOutcome::Created
586    };
587
588    Ok(InstallHookResult {
589        outcome,
590        hook_path: hook_path.display().to_string(),
591        hook_display_path: render_path(&hook_path, ctx.paths.home_dir()),
592        command_line: HOOK_COMMAND.to_string(),
593    })
594}
595
596/// Detect whether the hook is currently installed in the dotfiles
597/// repo. Used by the `dodot up` first-template-deploy prompt to
598/// decide whether to offer installation. Cheap (single read of the
599/// hook file).
600pub fn hook_is_installed(ctx: &ExecutionContext) -> Result<bool> {
601    let hook_path = ctx.paths.dotfiles_root().join(".git/hooks/pre-commit");
602    if !ctx.fs.exists(&hook_path) {
603        return Ok(false);
604    }
605    let existing = ctx.fs.read_to_string(&hook_path)?;
606    Ok(existing.contains(HOOK_GUARD_START))
607}
608
609/// Public for `dodot transform show-hook` (future) and for the
610/// onboarding prompt in `commands::up` to surface what would be
611/// installed. Includes the guard lines so callers can grep-detect
612/// the block in arbitrary contexts.
613///
614/// The block runs two commands:
615///
616/// 1. `dodot refresh --quiet` — touch source mtimes for any
617///    deployed-side edits so git's stat-cache invalidates. Without
618///    this, the clean filter (R6) wouldn't fire on the upcoming
619///    commit, and the commit could include stale template content.
620/// 2. `dodot transform check --strict` — run the 4-state matrix and
621///    refuse the commit on any finding (Conflict, missing,
622///    unresolved markers, NeedsRebaseline). `Patched` outcomes don't
623///    refuse — burgertocow's auto-merge already produced a clean
624///    unified patch and rewrote the source; the user `git add`s and
625///    commits the follow-up if they want a clean history.
626///
627/// Each step short-circuits with `|| exit 1`; a failure in either
628/// aborts the commit (with exit code 1 — the inner command's exit
629/// status is intentionally not preserved, since for git's purposes
630/// "any non-zero" is what blocks the commit).
631pub fn managed_block() -> String {
632    format!(
633        "{guard_start}\n\
634         # Aborts the commit if any template-source has drift that needs review —\n\
635         # divergent deployed file or unresolved dodot-conflict markers. Remove\n\
636         # this block to opt out.\n\
637         {refresh}\n\
638         {check}\n\
639         {guard_end}\n",
640        guard_start = HOOK_GUARD_START,
641        guard_end = HOOK_GUARD_END,
642        refresh = HOOK_COMMAND_REFRESH,
643        check = HOOK_COMMAND_CHECK,
644    )
645}
646
647/// First shell line of the managed block: invalidate git's
648/// stat-cache for any deployed-side edits. `--quiet` so a no-op
649/// refresh doesn't print on every commit.
650pub(crate) const HOOK_COMMAND_REFRESH: &str = "dodot refresh --quiet || exit 1";
651
652/// Second shell line: run the strict check. Splits across two lines
653/// in the hook so each step can be diagnosed independently.
654pub(crate) const HOOK_COMMAND_CHECK: &str = "dodot transform check --strict || exit 1";
655
656/// Combined "what the hook runs" string for display purposes
657/// (shown by the install message + the post-up prompt). The actual
658/// hook file uses the two-line form from [`managed_block`].
659pub(crate) const HOOK_COMMAND: &str = "dodot refresh --quiet && dodot transform check --strict";
660
661/// Locate the byte range of our managed block inside `text` —
662/// from the first character of `HOOK_GUARD_START` through the
663/// trailing newline after `HOOK_GUARD_END`. Returns `None` if either
664/// guard is missing or if the end guard doesn't appear after the
665/// start guard.
666///
667/// Used by the install path to detect stale managed blocks (and
668/// rewrite them to the current shape) without disturbing any
669/// non-managed content the user has in their hook.
670fn find_managed_block(text: &str) -> Option<(usize, usize)> {
671    let start = text.find(HOOK_GUARD_START)?;
672    // Find the end guard after `start`.
673    let after_start = start + HOOK_GUARD_START.len();
674    let end_rel = text[after_start..].find(HOOK_GUARD_END)?;
675    let end_guard_start = after_start + end_rel;
676    let end_byte = end_guard_start + HOOK_GUARD_END.len();
677    // Include the trailing newline (if any) so re-inserting the new
678    // block doesn't double-up the line break.
679    let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
680        end_byte + 1
681    } else {
682        end_byte
683    };
684    Some((start, end_byte))
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use crate::fs::Fs;
691    use crate::paths::Pather;
692    use crate::testing::TempEnvironment;
693
694    fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
695        use crate::config::ConfigManager;
696        use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
697        use crate::fs::Fs;
698        use crate::paths::Pather;
699        use std::sync::Arc;
700
701        struct NoopRunner;
702        impl CommandRunner for NoopRunner {
703            fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
704                Ok(CommandOutput {
705                    exit_code: 0,
706                    stdout: String::new(),
707                    stderr: String::new(),
708                })
709            }
710        }
711        let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
712        let datastore = Arc::new(FilesystemDataStore::new(
713            env.fs.clone(),
714            env.paths.clone(),
715            runner.clone(),
716        ));
717        let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
718        ExecutionContext {
719            fs: env.fs.clone() as Arc<dyn Fs>,
720            datastore,
721            paths: env.paths.clone() as Arc<dyn Pather>,
722            config_manager,
723            syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
724            command_runner: runner,
725            dry_run: false,
726            no_provision: true,
727            provision_rerun: false,
728            force: false,
729            view_mode: crate::commands::ViewMode::Full,
730            group_mode: crate::commands::GroupMode::Name,
731            verbose: false,
732        }
733    }
734
735    /// Run a real `dodot up` against a single-template pack so the
736    /// baseline cache + datastore are populated the same way they
737    /// would be in production. Returns the (pack_name,
738    /// source_path_in_pack) pair for the test to drive.
739    fn deploy_template(
740        env: &TempEnvironment,
741        pack: &str,
742        template_name: &str,
743        template_body: &str,
744        config_toml: &str,
745    ) -> std::path::PathBuf {
746        // Write the template source.
747        let src_path = env.dotfiles_root.join(pack).join(template_name);
748        env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
749        env.fs
750            .write_file(&src_path, template_body.as_bytes())
751            .unwrap();
752
753        // Write a root .dodot.toml carrying the desired vars.
754        if !config_toml.is_empty() {
755            env.fs
756                .write_file(
757                    &env.dotfiles_root.join(".dodot.toml"),
758                    config_toml.as_bytes(),
759                )
760                .unwrap();
761        }
762
763        // Deploy via `dodot up`.
764        let ctx = make_ctx(env);
765        let _ = crate::commands::up::up(None, &ctx).unwrap();
766
767        src_path
768    }
769
770    fn deployed_path(env: &TempEnvironment, pack: &str, filename: &str) -> std::path::PathBuf {
771        env.paths
772            .data_dir()
773            .join("packs")
774            .join(pack)
775            .join("preprocessed")
776            .join(filename)
777    }
778
779    #[test]
780    fn empty_cache_yields_clean_no_findings() {
781        let env = TempEnvironment::builder().build();
782        let ctx = make_ctx(&env);
783        let result = check(&ctx, false).unwrap();
784        assert!(result.entries.is_empty());
785        assert!(!result.has_findings);
786        assert_eq!(result.exit_code(), 0);
787    }
788
789    #[test]
790    fn synced_files_report_synced_and_no_findings() {
791        // Run `dodot up` on a template, immediately run `transform
792        // check`. Nothing edited → all entries are Synced, no findings.
793        let env = TempEnvironment::builder().build();
794        deploy_template(
795            &env,
796            "app",
797            "config.toml.tmpl",
798            "name = {{ name }}\n",
799            "[preprocessor.template.vars]\nname = \"Alice\"\n",
800        );
801        let ctx = make_ctx(&env);
802        let result = check(&ctx, false).unwrap();
803        assert_eq!(result.entries.len(), 1);
804        assert!(matches!(result.entries[0].action, TransformAction::Synced));
805        assert!(!result.has_findings);
806    }
807
808    #[test]
809    fn output_changed_static_edit_patches_source() {
810        // Edit the deployed file's static content. The source file's
811        // template variable should be preserved; the static edit
812        // should land in the template via diffy.
813        let env = TempEnvironment::builder().build();
814        let src_path = deploy_template(
815            &env,
816            "app",
817            "config.toml.tmpl",
818            "name = {{ name }}\nport = 5432\n",
819            "[preprocessor.template.vars]\nname = \"Alice\"\n",
820        );
821        // Edit the deployed file (the rendered content in the
822        // datastore — that's what the user-side symlink dereferences
823        // to). Change the static `port` line.
824        let deployed = deployed_path(&env, "app", "config.toml");
825        env.fs
826            .write_file(&deployed, b"name = Alice\nport = 9999\n")
827            .unwrap();
828
829        let ctx = make_ctx(&env);
830        let result = check(&ctx, false).unwrap();
831        assert_eq!(result.entries.len(), 1);
832        assert!(
833            matches!(result.entries[0].action, TransformAction::Patched),
834            "got: {:?}",
835            result.entries[0].action
836        );
837        // Patched is the auto-merge happy path: clean unified diff,
838        // source rewritten, nothing for the user to review. The
839        // pre-commit hook lets the commit proceed; the user does a
840        // follow-up `git add` + commit on the patched source. See #113.
841        assert!(!result.has_findings);
842        assert_eq!(result.exit_code(), 0);
843
844        // Source was rewritten: the static line is updated, the
845        // variable-bearing line is preserved verbatim.
846        let new_src = env.fs.read_to_string(&src_path).unwrap();
847        assert!(new_src.contains("port = 9999"), "src: {new_src:?}");
848        assert!(new_src.contains("name = {{ name }}"), "src: {new_src:?}");
849    }
850
851    #[test]
852    fn output_changed_pure_data_edit_yields_synced() {
853        // The user changed only the variable's *value* in the
854        // deployed file. burgertocow flags it as a pure-data edit;
855        // the source needs no change. Action: Synced (no findings,
856        // no source mutation).
857        let env = TempEnvironment::builder().build();
858        let src_path = deploy_template(
859            &env,
860            "app",
861            "config.toml.tmpl",
862            "name = {{ name }}\n",
863            "[preprocessor.template.vars]\nname = \"Alice\"\n",
864        );
865        let original_src = env.fs.read_to_string(&src_path).unwrap();
866        let deployed = deployed_path(&env, "app", "config.toml");
867        env.fs.write_file(&deployed, b"name = Bob\n").unwrap();
868
869        let ctx = make_ctx(&env);
870        let result = check(&ctx, false).unwrap();
871        assert_eq!(result.entries.len(), 1);
872        assert!(matches!(result.entries[0].action, TransformAction::Synced));
873        // Source must be byte-identical to the original.
874        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
875    }
876
877    #[test]
878    fn no_reverse_pattern_skips_reverse_merge() {
879        // Same scenario as output_changed_static_edit_patches_source,
880        // but with `no_reverse = ["config.toml.tmpl"]` in the root
881        // config. The user opted out of reverse-merge for this file
882        // — `transform check` must report Synced, leave the source
883        // untouched, and have no findings (so the pre-commit hook
884        // would let the commit through).
885        let env = TempEnvironment::builder().build();
886        let src_path = deploy_template(
887            &env,
888            "app",
889            "config.toml.tmpl",
890            "name = {{ name }}\nport = 5432\n",
891            "[preprocessor.template.vars]\n\
892             name = \"Alice\"\n\
893             [preprocessor.template]\n\
894             no_reverse = [\"config.toml.tmpl\"]\n",
895        );
896        let original_src = env.fs.read_to_string(&src_path).unwrap();
897
898        // Edit the deployed file the same way the patching test does.
899        let deployed = deployed_path(&env, "app", "config.toml");
900        env.fs
901            .write_file(&deployed, b"name = Alice\nport = 9999\n")
902            .unwrap();
903
904        let ctx = make_ctx(&env);
905        let result = check(&ctx, false).unwrap();
906        assert_eq!(result.entries.len(), 1);
907        assert!(
908            matches!(result.entries[0].action, TransformAction::Synced),
909            "no_reverse must short-circuit to Synced; got: {:?}",
910            result.entries[0].action
911        );
912        assert!(!result.has_findings);
913        assert_eq!(result.exit_code(), 0);
914        // Source untouched on disk.
915        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
916    }
917
918    #[test]
919    fn no_reverse_glob_pattern_skips_reverse_merge() {
920        // Glob form of the opt-out — `*.gen.tmpl` matches the
921        // generated template's filename and skips reverse-merge.
922        let env = TempEnvironment::builder().build();
923        let src_path = deploy_template(
924            &env,
925            "app",
926            "foo.gen.tmpl",
927            "name = {{ name }}\nport = 5432\n",
928            "[preprocessor.template.vars]\n\
929             name = \"Alice\"\n\
930             [preprocessor.template]\n\
931             no_reverse = [\"*.gen.tmpl\"]\n",
932        );
933        let original_src = env.fs.read_to_string(&src_path).unwrap();
934        let deployed = deployed_path(&env, "app", "foo.gen");
935        env.fs
936            .write_file(&deployed, b"name = Alice\nport = 9999\n")
937            .unwrap();
938
939        let ctx = make_ctx(&env);
940        let result = check(&ctx, false).unwrap();
941        assert_eq!(result.entries.len(), 1);
942        assert!(matches!(result.entries[0].action, TransformAction::Synced));
943        assert!(!result.has_findings);
944        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
945    }
946
947    #[test]
948    fn dry_run_does_not_write_to_source() {
949        // Same scenario as the static-edit patch test, but with
950        // dry_run=true. The action is still reported as Patched (so
951        // the user sees what *would* happen), but the source is left
952        // alone on disk.
953        let env = TempEnvironment::builder().build();
954        let src_path = deploy_template(
955            &env,
956            "app",
957            "config.toml.tmpl",
958            "name = {{ name }}\nport = 5432\n",
959            "[preprocessor.template.vars]\nname = \"Alice\"\n",
960        );
961        let original_src = env.fs.read_to_string(&src_path).unwrap();
962        let deployed = deployed_path(&env, "app", "config.toml");
963        env.fs
964            .write_file(&deployed, b"name = Alice\nport = 9999\n")
965            .unwrap();
966
967        let mut ctx = make_ctx(&env);
968        ctx.dry_run = true;
969        let result = check(&ctx, false).unwrap();
970        assert!(matches!(result.entries[0].action, TransformAction::Patched));
971        // Source unchanged on disk despite the action label.
972        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
973    }
974
975    #[test]
976    fn needs_rebaseline_when_tracked_render_is_empty_and_deployed_edited() {
977        // Forward-compat surface: a baseline written before
978        // tracked_render existed (or by a future preprocessor that
979        // opts in without producing a marker stream) is unable to
980        // drive burgertocow. If the deployed file has been edited,
981        // the action MUST be NeedsRebaseline — never silently
982        // reported as Synced. This test pins that contract because
983        // the bug existed in the first cut: empty tracked_render
984        // produced reverse_merge → Unchanged → mapped to Synced,
985        // hiding real divergence from the user.
986        let env = TempEnvironment::builder().build();
987        // Stage a baseline by hand with an empty tracked_render.
988        let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
989        env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
990        env.fs.write_file(&src_path, b"name = {{ name }}").unwrap();
991        let baseline = crate::preprocessing::baseline::Baseline::build(
992            &src_path,
993            b"name = Alice",
994            b"name = {{ name }}",
995            None, // <-- the load-bearing detail: no tracked render
996            None,
997        );
998        baseline
999            .write(
1000                env.fs.as_ref(),
1001                env.paths.as_ref(),
1002                "app",
1003                "preprocessed",
1004                "config.toml",
1005            )
1006            .unwrap();
1007        // Lay down a deployed file that DIVERGES from the baseline.
1008        let deployed = deployed_path(&env, "app", "config.toml");
1009        env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
1010        env.fs
1011            .write_file(&deployed, b"name = Edited\nport = 9999")
1012            .unwrap();
1013
1014        let ctx = make_ctx(&env);
1015        let result = check(&ctx, false).unwrap();
1016        assert_eq!(result.entries.len(), 1);
1017        assert!(
1018            matches!(result.entries[0].action, TransformAction::NeedsRebaseline),
1019            "got: {:?}",
1020            result.entries[0].action
1021        );
1022        assert!(
1023            result.has_findings,
1024            "NeedsRebaseline must count as a finding"
1025        );
1026        assert_eq!(result.exit_code(), 1);
1027
1028        // Source must NOT have been mutated (we couldn't compute a
1029        // safe diff without the marker stream).
1030        let src_after = env.fs.read_to_string(&src_path).unwrap();
1031        assert_eq!(src_after, "name = {{ name }}");
1032    }
1033
1034    #[test]
1035    fn missing_source_is_reported_with_finding() {
1036        // Stage a baseline with a source path that doesn't exist.
1037        // (Easier than going through `dodot up` and then deleting
1038        // the file.)
1039        let env = TempEnvironment::builder().build();
1040        // Build a minimal baseline by hand at the cache path.
1041        let baseline = crate::preprocessing::baseline::Baseline::build(
1042            &env.dotfiles_root.join("app/missing.toml.tmpl"),
1043            b"rendered",
1044            b"src",
1045            Some(""),
1046            None,
1047        );
1048        baseline
1049            .write(
1050                env.fs.as_ref(),
1051                env.paths.as_ref(),
1052                "app",
1053                "preprocessed",
1054                "missing.toml",
1055            )
1056            .unwrap();
1057        // Also lay down a deployed file so we don't conflate
1058        // MissingSource with MissingDeployed.
1059        let deployed = deployed_path(&env, "app", "missing.toml");
1060        env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
1061        env.fs.write_file(&deployed, b"rendered").unwrap();
1062
1063        let ctx = make_ctx(&env);
1064        let result = check(&ctx, false).unwrap();
1065        assert!(matches!(
1066            result.entries[0].action,
1067            TransformAction::MissingSource
1068        ));
1069        assert!(result.has_findings);
1070    }
1071
1072    #[test]
1073    fn strict_mode_flags_unresolved_marker_in_source() {
1074        // Deploy a template, then write dodot-conflict markers into
1075        // the source file (simulating a previous `transform check`
1076        // run that emitted them). Strict mode catches it.
1077        let env = TempEnvironment::builder().build();
1078        let src_path = deploy_template(
1079            &env,
1080            "app",
1081            "config.toml.tmpl",
1082            "name = {{ name }}\n",
1083            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1084        );
1085        let dirty = format!(
1086            "first\n{}\nbody\n{}\n",
1087            crate::preprocessing::conflict::MARKER_START,
1088            crate::preprocessing::conflict::MARKER_END,
1089        );
1090        env.fs.write_file(&src_path, dirty.as_bytes()).unwrap();
1091
1092        let ctx = make_ctx(&env);
1093        // Non-strict: no marker scan, so no findings (the source
1094        // change makes it InputChanged, which is fine).
1095        let lax = check(&ctx, false).unwrap();
1096        assert!(lax.unresolved_markers.is_empty());
1097
1098        // Strict: scan picks up the markers, has_findings=true.
1099        let strict = check(&ctx, true).unwrap();
1100        assert_eq!(strict.unresolved_markers.len(), 1);
1101        assert_eq!(strict.unresolved_markers[0].line_numbers, vec![2, 4]);
1102        assert!(strict.has_findings);
1103        assert_eq!(strict.exit_code(), 1);
1104    }
1105
1106    #[test]
1107    fn strict_mode_clean_repo_is_zero_findings() {
1108        // No source has markers → strict mode reports zero unresolved
1109        // markers and (assuming no divergence either) no findings.
1110        let env = TempEnvironment::builder().build();
1111        deploy_template(
1112            &env,
1113            "app",
1114            "config.toml.tmpl",
1115            "name = {{ name }}\n",
1116            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1117        );
1118        let ctx = make_ctx(&env);
1119        let result = check(&ctx, true).unwrap();
1120        assert!(result.unresolved_markers.is_empty());
1121        assert!(!result.has_findings);
1122        assert_eq!(result.exit_code(), 0);
1123    }
1124
1125    #[test]
1126    fn paths_are_rendered_relative_to_home_for_display() {
1127        // Deployed paths under `data_dir` (which lives under the
1128        // sandbox $HOME) should render with `~/` prefix in the
1129        // report. Pure cosmetic — `dodot transform check`'s output
1130        // is meant to be readable in a terminal.
1131        let env = TempEnvironment::builder().build();
1132        deploy_template(
1133            &env,
1134            "app",
1135            "config.toml.tmpl",
1136            "name = {{ name }}\n",
1137            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1138        );
1139        let ctx = make_ctx(&env);
1140        let result = check(&ctx, false).unwrap();
1141        // At least one of source/deployed should start with `~/`.
1142        let entry = &result.entries[0];
1143        assert!(
1144            entry.source_path.starts_with("~/") || entry.deployed_path.starts_with("~/"),
1145            "expected ~/-relative paths in report, got source={} deployed={}",
1146            entry.source_path,
1147            entry.deployed_path
1148        );
1149    }
1150
1151    // ── status ──────────────────────────────────────────────────
1152
1153    #[test]
1154    fn status_on_clean_repo_reports_one_synced_row() {
1155        let env = TempEnvironment::builder().build();
1156        deploy_template(
1157            &env,
1158            "app",
1159            "config.toml.tmpl",
1160            "name = {{ name }}\n",
1161            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1162        );
1163        let ctx = make_ctx(&env);
1164        let result = status(&ctx).unwrap();
1165        assert_eq!(result.entries.len(), 1);
1166        assert_eq!(result.entries[0].state, "synced");
1167        assert_eq!(result.synced_count, 1);
1168        assert_eq!(result.diverged_count, 0);
1169        assert_eq!(result.missing_count, 0);
1170    }
1171
1172    #[test]
1173    fn status_surfaces_secret_references_from_sidecar() {
1174        // Phase S5: a baseline with a sidecar exposes the
1175        // resolved references in `transform status`. The
1176        // user can see WHICH secrets each baseline depends on
1177        // without re-rendering.
1178        let env = TempEnvironment::builder().build();
1179        deploy_template(
1180            &env,
1181            "app",
1182            "config.toml.tmpl",
1183            "name = {{ name }}\n",
1184            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1185        );
1186        // Drop a sidecar next to the baseline. (In production
1187        // the renderer writes this; tests can build it
1188        // directly since the file shape is stable.)
1189        let sidecar = crate::preprocessing::baseline::SecretsSidecar::new(vec![
1190            crate::preprocessing::SecretLineRange {
1191                start: 0,
1192                end: 1,
1193                reference: "pass:test/db_password".into(),
1194            },
1195            crate::preprocessing::SecretLineRange {
1196                start: 2,
1197                end: 3,
1198                reference: "op://Personal/api/token".into(),
1199            },
1200        ]);
1201        sidecar
1202            .write(
1203                env.fs.as_ref(),
1204                env.paths.as_ref(),
1205                "app",
1206                "preprocessed",
1207                "config.toml",
1208            )
1209            .unwrap();
1210
1211        let ctx = make_ctx(&env);
1212        let result = status(&ctx).unwrap();
1213        assert_eq!(result.entries.len(), 1);
1214        assert_eq!(
1215            result.entries[0].secret_references,
1216            vec![
1217                "pass:test/db_password".to_string(),
1218                "op://Personal/api/token".to_string(),
1219            ]
1220        );
1221    }
1222
1223    #[test]
1224    fn status_returns_empty_secret_references_when_no_sidecar() {
1225        // Default state: a template that doesn't use secrets
1226        // has no sidecar, so `secret_references` is the empty
1227        // vec. The serde `skip_serializing_if = "Vec::is_empty"`
1228        // attribute means JSON consumers don't see the field at
1229        // all in this case — pin the rust-side state too.
1230        let env = TempEnvironment::builder().build();
1231        deploy_template(
1232            &env,
1233            "app",
1234            "config.toml.tmpl",
1235            "name = {{ name }}\n",
1236            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1237        );
1238        let ctx = make_ctx(&env);
1239        let result = status(&ctx).unwrap();
1240        assert!(result.entries[0].secret_references.is_empty());
1241    }
1242
1243    #[test]
1244    fn status_classifies_output_change() {
1245        let env = TempEnvironment::builder().build();
1246        deploy_template(
1247            &env,
1248            "app",
1249            "config.toml.tmpl",
1250            "name = {{ name }}\nport = 5432\n",
1251            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1252        );
1253        let deployed = deployed_path(&env, "app", "config.toml");
1254        env.fs
1255            .write_file(&deployed, b"name = Alice\nport = 9999\n")
1256            .unwrap();
1257
1258        let ctx = make_ctx(&env);
1259        let result = status(&ctx).unwrap();
1260        assert_eq!(result.entries[0].state, "output_changed");
1261        assert_eq!(result.diverged_count, 1);
1262        assert_eq!(result.synced_count, 0);
1263    }
1264
1265    #[test]
1266    fn status_does_not_mutate_anything() {
1267        // The entire point of `status` (vs `check`) is that it's
1268        // read-only. Run it on a divergent repo and confirm the
1269        // source file is byte-identical afterwards.
1270        let env = TempEnvironment::builder().build();
1271        let src = deploy_template(
1272            &env,
1273            "app",
1274            "config.toml.tmpl",
1275            "name = {{ name }}\nport = 5432\n",
1276            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1277        );
1278        let original_src = env.fs.read_to_string(&src).unwrap();
1279        let deployed = deployed_path(&env, "app", "config.toml");
1280        env.fs
1281            .write_file(&deployed, b"name = Alice\nport = 9999\n")
1282            .unwrap();
1283
1284        let ctx = make_ctx(&env);
1285        let _ = status(&ctx).unwrap();
1286        assert_eq!(env.fs.read_to_string(&src).unwrap(), original_src);
1287    }
1288
1289    #[test]
1290    fn status_empty_cache_yields_zero_counts() {
1291        let env = TempEnvironment::builder().build();
1292        let ctx = make_ctx(&env);
1293        let result = status(&ctx).unwrap();
1294        assert!(result.entries.is_empty());
1295        assert_eq!(result.synced_count, 0);
1296        assert_eq!(result.diverged_count, 0);
1297        assert_eq!(result.missing_count, 0);
1298    }
1299
1300    // ── install_hook ────────────────────────────────────────────
1301
1302    /// Stand up a fake `.git` directory inside the dotfiles_root so
1303    /// `install_hook` recognises the dotfiles repo as a git working
1304    /// tree. We don't `git init` for real because every test would
1305    /// pay the subprocess cost; the installer only checks for
1306    /// `.git` as a dir, so a bare `mkdir` suffices.
1307    fn fake_git_dir(env: &TempEnvironment) {
1308        env.fs
1309            .mkdir_all(&env.dotfiles_root.join(".git/hooks"))
1310            .unwrap();
1311    }
1312
1313    #[test]
1314    fn install_hook_creates_new_pre_commit_when_absent() {
1315        let env = TempEnvironment::builder().build();
1316        fake_git_dir(&env);
1317        // Make sure the hooks dir exists but the hook file does not.
1318        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1319        assert!(!env.fs.exists(&hook_path));
1320
1321        let ctx = make_ctx(&env);
1322        let result = install_hook(&ctx).unwrap();
1323        assert!(matches!(result.outcome, InstallHookOutcome::Created));
1324        assert!(env.fs.exists(&hook_path));
1325
1326        let body = env.fs.read_to_string(&hook_path).unwrap();
1327        assert!(body.starts_with("#!/bin/sh\n"), "body: {body:?}");
1328        assert!(body.contains(HOOK_GUARD_START), "body: {body:?}");
1329        assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
1330        assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
1331        assert!(body.contains(HOOK_GUARD_END), "body: {body:?}");
1332    }
1333
1334    #[test]
1335    fn install_hook_appends_to_existing_pre_commit() {
1336        // The user already has a hook (e.g. installed by another tool
1337        // or a personal script). install_hook must preserve that
1338        // content and append our block, not clobber it.
1339        let env = TempEnvironment::builder().build();
1340        fake_git_dir(&env);
1341        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1342        let existing = "#!/bin/sh\necho 'my pre-commit'\nexit 0\n";
1343        env.fs.write_file(&hook_path, existing.as_bytes()).unwrap();
1344
1345        let ctx = make_ctx(&env);
1346        let result = install_hook(&ctx).unwrap();
1347        assert!(matches!(result.outcome, InstallHookOutcome::Appended));
1348
1349        let body = env.fs.read_to_string(&hook_path).unwrap();
1350        assert!(body.starts_with(existing), "user content lost: {body:?}");
1351        assert!(body.contains(HOOK_GUARD_START));
1352        assert!(body.contains(HOOK_COMMAND_REFRESH));
1353        assert!(body.contains(HOOK_COMMAND_CHECK));
1354    }
1355
1356    #[test]
1357    fn install_hook_is_idempotent_on_second_call() {
1358        // Running `dodot transform install-hook` twice in a row must
1359        // not double-append the block. The guard line is what makes
1360        // this safe.
1361        let env = TempEnvironment::builder().build();
1362        fake_git_dir(&env);
1363        let ctx = make_ctx(&env);
1364
1365        let r1 = install_hook(&ctx).unwrap();
1366        assert!(matches!(r1.outcome, InstallHookOutcome::Created));
1367
1368        let body_after_first = env
1369            .fs
1370            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1371            .unwrap();
1372
1373        let r2 = install_hook(&ctx).unwrap();
1374        assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
1375
1376        let body_after_second = env
1377            .fs
1378            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1379            .unwrap();
1380        assert_eq!(
1381            body_after_first, body_after_second,
1382            "body changed on second call"
1383        );
1384        // Exactly one occurrence of the guard line.
1385        assert_eq!(body_after_second.matches(HOOK_GUARD_START).count(), 1);
1386    }
1387
1388    #[test]
1389    fn install_hook_errors_if_no_git_dir() {
1390        // If the dotfiles root isn't a git working tree, refuse
1391        // with a clear error rather than silently writing a hook
1392        // that nothing will ever invoke.
1393        let env = TempEnvironment::builder().build();
1394        let ctx = make_ctx(&env);
1395        let err = install_hook(&ctx).unwrap_err();
1396        let msg = format!("{err}");
1397        assert!(msg.contains("no .git directory"), "msg: {msg}");
1398        assert!(msg.contains("git init"), "msg: {msg}");
1399    }
1400
1401    #[test]
1402    fn hook_is_installed_reports_correctly() {
1403        let env = TempEnvironment::builder().build();
1404        fake_git_dir(&env);
1405        let ctx = make_ctx(&env);
1406
1407        // No hook yet → not installed.
1408        assert!(!hook_is_installed(&ctx).unwrap());
1409
1410        // Install it → reported as installed.
1411        install_hook(&ctx).unwrap();
1412        assert!(hook_is_installed(&ctx).unwrap());
1413
1414        // A user-written hook without our guard → not installed
1415        // (from our perspective).
1416        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1417        env.fs
1418            .write_file(&hook_path, b"#!/bin/sh\necho hello\n")
1419            .unwrap();
1420        assert!(!hook_is_installed(&ctx).unwrap());
1421    }
1422
1423    #[test]
1424    fn install_hook_sets_executable_bit() {
1425        // The hook needs +x to be invoked by git. Confirm we set
1426        // the bit on both the create and the append paths.
1427        use std::os::unix::fs::PermissionsExt;
1428
1429        let env = TempEnvironment::builder().build();
1430        fake_git_dir(&env);
1431        let ctx = make_ctx(&env);
1432        install_hook(&ctx).unwrap();
1433
1434        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1435        let mode = std::fs::metadata(&hook_path).unwrap().permissions().mode();
1436        // owner-execute bit must be set; we test for any execute
1437        // rather than exact 0o755 because the OS may apply umask.
1438        assert!(
1439            mode & 0o100 != 0,
1440            "hook is not executable, mode = {:o}",
1441            mode
1442        );
1443    }
1444
1445    #[test]
1446    fn managed_block_is_self_contained_and_grep_detectable() {
1447        // The block alone should be enough to detect the install:
1448        // its first line is exactly HOOK_GUARD_START. This pins the
1449        // contract that downstream tools (or future `transform
1450        // uninstall-hook`) can grep for the guard line.
1451        let block = managed_block();
1452        assert!(block.starts_with(HOOK_GUARD_START));
1453        assert!(block.trim_end().ends_with(HOOK_GUARD_END));
1454        // Both shell lines (refresh + check) must appear in the
1455        // block — the hook runs them as two independent steps.
1456        assert!(block.contains(HOOK_COMMAND_REFRESH));
1457        assert!(block.contains(HOOK_COMMAND_CHECK));
1458    }
1459
1460    // ── hook upgrade (managed-block detection + replacement) ────
1461
1462    #[test]
1463    fn install_hook_replaces_a_stale_managed_block() {
1464        // An older R4-shape block (single check command, no refresh
1465        // line) must be detected and rewritten to the new two-line
1466        // form when `install-hook` runs again. Existing non-managed
1467        // content is preserved.
1468        let env = TempEnvironment::builder().build();
1469        fake_git_dir(&env);
1470
1471        // Stage an old-style block manually. This is what an R4-era
1472        // install-hook would have produced: the same guards, but the
1473        // single old `dodot transform check --strict || exit 1`
1474        // command line and the older comment.
1475        let stale = format!(
1476            "#!/bin/sh\n\
1477             echo 'user-installed pre-commit step'\n\
1478             \n\
1479             {start}\n\
1480             # Old-style block from R4. Still works, but doesn't run\n\
1481             # `dodot refresh` first, so deployed-side edits between\n\
1482             # commits aren't always picked up.\n\
1483             dodot transform check --strict || exit 1\n\
1484             {end}\n\
1485             # User content after the block.\n\
1486             echo 'trailing user step'\n",
1487            start = HOOK_GUARD_START,
1488            end = HOOK_GUARD_END,
1489        );
1490        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1491        env.fs.write_file(&hook_path, stale.as_bytes()).unwrap();
1492
1493        let ctx = make_ctx(&env);
1494        let result = install_hook(&ctx).unwrap();
1495        assert!(matches!(result.outcome, InstallHookOutcome::Updated));
1496
1497        let body = env.fs.read_to_string(&hook_path).unwrap();
1498        // New shape: both refresh + check lines, comment matches the
1499        // current block.
1500        assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
1501        assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
1502        // User content (before AND after the managed block) survived.
1503        assert!(body.contains("user-installed pre-commit step"));
1504        assert!(body.contains("trailing user step"));
1505        // Exactly one managed block — no duplicates.
1506        assert_eq!(body.matches(HOOK_GUARD_START).count(), 1);
1507        assert_eq!(body.matches(HOOK_GUARD_END).count(), 1);
1508    }
1509
1510    #[test]
1511    fn install_hook_no_op_on_current_block() {
1512        // The exact opposite of the upgrade test: if the existing
1513        // block is already the current shape, install_hook returns
1514        // AlreadyInstalled and leaves the file byte-identical.
1515        let env = TempEnvironment::builder().build();
1516        fake_git_dir(&env);
1517        let ctx = make_ctx(&env);
1518
1519        // Install fresh.
1520        let r1 = install_hook(&ctx).unwrap();
1521        assert!(matches!(r1.outcome, InstallHookOutcome::Created));
1522        let body_after_first = env
1523            .fs
1524            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1525            .unwrap();
1526
1527        // Re-install — current block is up to date, no change.
1528        let r2 = install_hook(&ctx).unwrap();
1529        assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
1530        let body_after_second = env
1531            .fs
1532            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1533            .unwrap();
1534        assert_eq!(body_after_first, body_after_second);
1535    }
1536
1537    #[test]
1538    fn find_managed_block_locates_byte_range() {
1539        // White-box test for the byte-range finder so we don't have
1540        // to reverse-engineer it from the splice tests above.
1541        let block = managed_block();
1542        let prefix = "before\n";
1543        let suffix = "after\n";
1544        let text = format!("{prefix}{block}{suffix}");
1545        let (start, end) = find_managed_block(&text).expect("must find block");
1546        assert_eq!(&text[start..end], block);
1547    }
1548
1549    #[test]
1550    fn find_managed_block_returns_none_when_absent() {
1551        assert!(find_managed_block("nothing here").is_none());
1552        // Half-block (start without end) → also None: we treat
1553        // partial blocks as "not installed" so install_hook will
1554        // append rather than try to splice.
1555        let only_start = format!("{HOOK_GUARD_START}\nrandom content\n");
1556        assert!(find_managed_block(&only_start).is_none());
1557    }
1558}