diff --git a/e2e/cli/test_doctor_dotfiles_history_disabled b/e2e/cli/test_doctor_dotfiles_history_disabled new file mode 100644 index 00000000000..63bb449f9c9 --- /dev/null +++ b/e2e/cli/test_doctor_dotfiles_history_disabled @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# doctor still shows where dotfiles live when history.enabled = false + +git init -q --bare -b main "$PWD/origin.git" +echo original >"$HOME/.native" +assert_succeed "mise bootstrap dotfiles track ~/.native --no-autosave" +assert_succeed "mise bootstrap dotfiles origin set file://$PWD/origin.git --sync manual --yes" + +assert_contains "mise doctor 2>&1 || true" "1 tracked entry, watcher" + +export MISE_HISTORY_ENABLED=0 +assert_contains "mise doctor 2>&1 || true" "1 tracked entry, history disabled (history.enabled = false): nothing is saved or synced" +assert_contains "mise doctor 2>&1 || true" "repo: " +assert_contains "mise doctor 2>&1 || true" "origin.git (branch main, sync manual)" +assert "mise doctor --json 2>/dev/null | jq -r '.dotfiles.history_enabled, .dotfiles.tracked, .dotfiles.origin.url'" "false +1 +file://$PWD/origin.git" + +# nothing tracked, no history, no setup repository: no section +export MISE_STATE_DIR="$PWD/empty-state" +export MISE_CONFIG_DIR="$PWD/empty-config" +assert_not_contains "mise doctor 2>&1 || true" "dotfiles:" + +# a declaration that cannot be read is reported, not taken for none +mkdir -p "$MISE_CONFIG_DIR" +cat >"$MISE_CONFIG_DIR/config.toml" <<'TOML' +[history.encryption] +recipients = [] +[dotfiles] +"~/.private-value" = { mode = "track", encrypt = true } +TOML +assert_contains "mise doctor 2>&1 || true" "tracked entries could not be read ([history.encryption].recipients must not be empty" diff --git a/src/cli/doctor/mod.rs b/src/cli/doctor/mod.rs index faea280ddef..46f626caf4e 100644 --- a/src/cli/doctor/mod.rs +++ b/src/cli/doctor/mod.rs @@ -80,11 +80,16 @@ enum SystemDefaultsDiagnosis { /// outcome of the `[bootstrap.user].login_shell` doctor check #[derive(serde::Serialize)] struct DotfilesDiagnosis { + /// `history.enabled`: when false, nothing is saved or synced and only + /// `repo`, `origin`, and `tracked` are reported. + history_enabled: bool, /// The local bare repository that holds the history. repo: PathBuf, /// The connected setup repository, if any. origin: Option, tracked: usize, + /// Why the tracked entries could not be read, when history is disabled. + tracking_error: Option, watcher: String, stale: bool, health_age_secs: Option, @@ -111,6 +116,59 @@ struct DotfilesOriginDiagnosis { sync: String, } +/// The connected setup repository, if any. +fn dotfiles_origin() -> Option { + crate::system::history::sync::run::origin() + .ok() + .map(|origin| DotfilesOriginDiagnosis { + url: origin.url, + branch: origin.branch, + sync: crate::system::history::sync::SyncMode::current() + .map(|mode| mode.as_str().to_string()) + .unwrap_or_else(|_| "unknown".to_string()), + }) +} + +/// Where the dotfiles history would live with `history.enabled = false`: +/// the store is closed, so nothing about capture or sync health applies. +/// Reads the declared tracking only, never opening or creating the store. +/// Returns `None` when nothing is tracked, no history exists, and no setup +/// repository is connected. +async fn check_disabled_dotfiles() -> Option { + let config = Config::get().await.ok()?; + // a declaration that cannot be read is reported, not taken for none + let (tracked, tracking_error) = + match crate::system::history::tracked::TrackedSet::from_config(&config) { + Ok(set) => (set.entries.len(), None), + Err(err) => (0, Some(format!("{err:#}"))), + }; + let repo = + crate::system::history::store::repo_dir_in(&crate::system::history::store::state_dir()); + let origin = dotfiles_origin(); + if tracked == 0 && tracking_error.is_none() && !repo.is_dir() && origin.is_none() { + return None; + } + Some(DotfilesDiagnosis { + history_enabled: false, + repo, + origin, + tracked, + tracking_error, + watcher: "disabled".to_string(), + stale: false, + health_age_secs: None, + unavailable: None, + last_error: None, + degraded: vec![], + throttled: vec![], + sync_conflicts: vec![], + sync_error: None, + sync_failing_for_secs: None, + sync_failures: 0, + notifications: "disabled".to_string(), + }) +} + /// How long syncs have been failing: since the current run of failures /// began, or, for a record from before that was kept, since the last /// success. `None` when nothing is known. @@ -778,7 +836,7 @@ impl Doctor { use crate::system::history::health; use crate::system::history::tracked::TrackedSet; if !crate::config::Settings::get().history.enabled { - return None; + return check_disabled_dotfiles().await; } let tracked = TrackedSet::effective().await.ok()?; let watcher = crate::cli::dotfiles::capture_health::watcher().await.ok()?; @@ -799,19 +857,12 @@ impl Doctor { .map(|d| d.as_secs()) .unwrap_or(600); let stale = running && age.is_some_and(|age| reconcile > 0 && age > reconcile * 2); - let origin = crate::system::history::sync::run::origin() - .ok() - .map(|origin| DotfilesOriginDiagnosis { - url: origin.url, - branch: origin.branch, - sync: crate::system::history::sync::SyncMode::current() - .map(|mode| mode.as_str().to_string()) - .unwrap_or_else(|_| "unknown".to_string()), - }); let mut diagnosis = DotfilesDiagnosis { + history_enabled: true, repo: crate::system::history::store::repo_dir_in(&state_dir), - origin, + origin: dotfiles_origin(), tracked: tracks, + tracking_error: None, watcher: watcher.as_str().to_string(), stale, health_age_secs: age, @@ -950,12 +1001,22 @@ impl Doctor { let Some(diagnosis) = self.check_dotfiles().await else { return Ok(()); }; - let mut lines = vec![format!( - "{} tracked entr{}, watcher {}", + let entries = format!( + "{} tracked entr{}", diagnosis.tracked, - if diagnosis.tracked == 1 { "y" } else { "ies" }, - diagnosis.watcher - )]; + if diagnosis.tracked == 1 { "y" } else { "ies" } + ); + let mut lines = vec![if diagnosis.history_enabled { + format!("{entries}, watcher {}", diagnosis.watcher) + } else if let Some(error) = &diagnosis.tracking_error { + format!( + "tracked entries could not be read ({error}); history disabled (history.enabled = false): nothing is saved or synced" + ) + } else { + format!( + "{entries}, history disabled (history.enabled = false): nothing is saved or synced" + ) + }]; lines.push(format!("repo: {}", display_path(&diagnosis.repo))); if let Some(origin) = &diagnosis.origin { lines.push(format!( @@ -963,6 +1024,10 @@ impl Doctor { origin.url, origin.branch, origin.sync )); } + if !diagnosis.history_enabled { + info::section("dotfiles", lines.join("\n"))?; + return Ok(()); + } if diagnosis.stale { lines.push(format!( "health information is stale (last update {} ago); the watcher may be stuck",