use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use eyre::Result;
use globset::{Glob, GlobSet, GlobSetBuilder};
use walkdir::WalkDir;
use super::select::{self, Selection};
use super::shadow::{CaptureRoot, MAX_BYTES, MAX_FILE_BYTES, MAX_FILES};
use super::store::{Coverage, CoverageEntry, PathReason};
use crate::config::Config;
use crate::dirs;
use crate::file::{self, display_path};
use crate::system::files::{FileMode, FilePolicy};
const CREDENTIAL_NAMES: &[&str] = &["github_tokens.toml", "hosts.yml", "age.txt"];
const CREDENTIAL_GLOBS: &[&str] = &[
".netrc",
"*.age",
"*.key",
"*.pem",
"*.gpg",
"*.kdbx",
"id_*",
"*token*",
"*secret*",
"credentials*",
"oauth*",
];
pub(crate) type Policy = FilePolicy;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub(crate) struct TrackedEntry {
pub path: PathBuf,
pub mode: String,
pub policy: Policy,
pub variant: Option<String>,
pub declared_in: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<String>>,
}
impl TrackedEntry {
pub(crate) fn exclude_patterns(&self) -> Vec<glob::Pattern> {
self.exclude
.iter()
.flatten()
.filter_map(|pattern| glob::Pattern::new(pattern).ok())
.collect()
}
pub(crate) fn is_excluded(&self, path: &Path) -> bool {
excluded_by_entry(&self.path, self.exclude.as_deref().unwrap_or(&[]), path)
}
pub(crate) fn include_patterns(&self) -> Option<Vec<glob::Pattern>> {
Some(
self.include
.as_ref()?
.iter()
.filter_map(|pattern| glob::Pattern::new(pattern).ok())
.collect(),
)
}
pub(crate) fn include_prunes(&self, directory: &Path) -> bool {
let Some(patterns) = self.include.as_ref() else {
return false;
};
let Ok(rel) = directory.strip_prefix(&self.path) else {
return false;
};
let components = path_components(rel);
!patterns
.iter()
.any(|pattern| reaches_into(pattern, &components))
}
pub(crate) fn include_reaching_into(&self, directory: &Path) -> Option<&str> {
let rel = directory.strip_prefix(&self.path).ok()?;
let components = path_components(rel);
self.include
.iter()
.flatten()
.find(|pattern| reaches_into(pattern, &components))
.map(String::as_str)
}
pub(crate) fn is_included(&self, path: &Path) -> bool {
let Some(patterns) = self.include_patterns() else {
return true;
};
match path.strip_prefix(&self.path) {
Ok(rel) if !rel.as_os_str().is_empty() => {
crate::system::files::is_excluded(&pattern_relative(rel), &patterns)
}
_ => false,
}
}
fn selected_by_pattern(&self, path: &Path) -> bool {
self.include.is_some() && self.is_included(path)
}
pub(crate) fn capture_exclusion(&self, path: &Path) -> Option<&'static str> {
let reason = capture_exclusion(path, &self.policy)?;
if reason == CREDENTIAL_REASON && self.selected_by_pattern(path) {
return None;
}
Some(reason)
}
pub(crate) fn tree_path(&self, path: &Path) -> Result<String> {
super::sync::layout::Roots::current()
.branch_path(path, self.variant.as_deref())
.ok_or_else(|| {
eyre::eyre!(
"cannot represent tracked path {} portably",
display_path(path)
)
})
}
pub(crate) fn display(&self) -> String {
display_path(&self.path)
}
pub(crate) fn new(path: PathBuf, mode: &str, policy: Policy) -> Self {
Self {
path,
mode: mode.to_string(),
policy,
variant: None,
declared_in: None,
exclude: None,
include: None,
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct TrackedSet {
pub entries: Vec<TrackedEntry>,
pub manifest: super::manifest::Manifest,
pub declarations: Option<super::manifest::Manifest>,
pub disabled: Vec<PathBuf>,
pub required_sources: Vec<PathBuf>,
pub exclude: Vec<String>,
pub invalid: Vec<PathReason>,
}
#[derive(Debug, Default)]
pub(crate) struct Walk {
pub manifest: super::manifest::Manifest,
pub entries: Vec<TrackedEntry>,
pub roots: Vec<CaptureRoot>,
pub files: BTreeMap<PathBuf, (usize, Policy)>,
pub omitted: Vec<PathReason>,
pub plaintext: Vec<PathReason>,
pub considered: BTreeMap<usize, u64>,
pub skipped: BTreeSet<usize>,
pub nested: Vec<PathReason>,
pub incomplete: Vec<PathReason>,
pub warnings: Vec<String>,
pub capture_warnings: Vec<String>,
}
impl TrackedSet {
pub(crate) async fn effective() -> Result<Self> {
let config = Config::get().await?;
let declared = Self::from_config(&config)?;
if !super::shadow::HistoryRepo::path_in(&dirs::STATE).is_dir() {
return Ok(declared);
}
match super::shadow::HistoryRepo::open_or_init_in(&dirs::STATE)? {
Some(repo) => super::enrollment::resolve(&dirs::STATE, &repo, &declared, &[], &[]),
None => Ok(declared),
}
}
pub(crate) fn from_config(config: &Config) -> Result<Self> {
let mut set = Self {
exclude: super::config::exclude_globs()?,
..Default::default()
};
let requests = crate::system::files::composed_files_from_config(config)?
.into_iter()
.filter(|request| crate::system::files::declaration_is_global(config, request));
set.add_requests(requests);
for invalid in crate::system::files::invalid_declarations() {
if !crate::system::files::tracking_config_is_global(config, &invalid.config) {
continue;
}
set.invalid.push(PathReason {
path: invalid.target,
reason: format!("{} ({})", invalid.reason, display_path(&invalid.config)),
});
}
set.manifest.exclude = set.exclude.clone();
if set.manifest.enrollment.iter().any(|entry| entry.encrypt) {
set.manifest.recipients = super::config::file_recipients()?;
}
set.declarations = Some(set.manifest.clone());
Ok(set)
}
pub(crate) fn add_requests(
&mut self,
requests: impl IntoIterator<Item = crate::system::files::FileRequest>,
) {
let set = self;
let environments = select::active_environments();
for request in requests {
if !request.enabled && request.mode == FileMode::Track {
set.disabled.push(normalize_target(&request.target));
}
if request.enabled
&& matches!(
request.mode,
FileMode::Symlink | FileMode::SymlinkEach | FileMode::Copy | FileMode::Template
)
{
let source = normalize_target(&request.source);
if !set.required_sources.contains(&source) {
set.required_sources.push(source);
}
}
if !request.enabled || request.mode != FileMode::Track {
continue;
}
if let Err(error) = ensure_portable_ancestors(&request.target) {
set.invalid.push(PathReason {
path: display_path(&request.target),
reason: error.to_string(),
});
continue;
}
let target = normalize_target(&request.target);
let portable =
if let Ok(relative) = target.strip_prefix(normalize(&global_config_dir())) {
Some(
format!("config/{}", relative.to_string_lossy().replace('\\', "/"))
.trim_end_matches('/')
.to_owned(),
)
} else if let Ok(relative) = target.strip_prefix(normalize(&dirs::HOME)) {
Some(format!(
"home/{}",
relative.to_string_lossy().replace('\\', "/")
))
} else {
None
};
let Some(path) = portable.filter(|path| super::sync::layout::is_safe_branch_path(path))
else {
set.invalid.push(PathReason { path: display_path(&target), reason: "tracking requires a portable path under home or the mise configuration directory".into() });
continue;
};
if let Err(error) = select::validate(&request.variants) {
set.invalid.push(PathReason {
path: display_path(&target),
reason: error.to_string(),
});
continue;
}
set.manifest.enrollment.retain(|entry| entry.path != path);
set.manifest.enrollment.push(super::manifest::Enrollment {
path,
autosave: request.policy.autosave,
encrypt: request.policy.encrypt,
variants: request.variants.clone(),
exclude: declared_exclude(&request),
include: request.include.as_ref().map(|patterns| {
patterns
.iter()
.map(|pattern| pattern.as_str().to_owned())
.collect()
}),
});
set.manifest.enrollment.sort_by(|a, b| a.path.cmp(&b.path));
let declared_in = Some(request.origin.config.clone());
match request.mode {
FileMode::Track => {
let mut entry = TrackedEntry::new(
normalize_target(&request.target),
"track",
request.policy,
);
entry.declared_in = declared_in;
entry.exclude = declared_exclude(&request);
entry.include = request.include.as_ref().map(|patterns| {
patterns
.iter()
.map(|pattern| pattern.as_str().to_owned())
.collect()
});
match select::select(&request.variants, &environments) {
Selection::Single => {}
Selection::Variant(variant) => {
entry.variant = Some(variant.name());
}
Selection::NoMatch => continue,
Selection::Ambiguous(variants) => {
let names: Vec<String> =
variants.iter().map(|variant| variant.name()).collect();
set.invalid.push(PathReason {
path: display_path(&request.target),
reason: format!(
"ambiguous variant: {} match this machine equally",
names.join(" and ")
),
});
continue;
}
}
set.push(entry);
}
_ => unreachable!("only explicit tracking requests are enrolled"),
}
}
}
pub(crate) fn push(&mut self, entry: TrackedEntry) {
if let Some(existing) = self
.entries
.iter_mut()
.find(|existing| existing.path == entry.path)
{
if existing.policy.encrypt != entry.policy.encrypt {
self.invalid.push(PathReason {
path: display_path(&entry.path),
reason: "overlapping declarations disagree about encryption".into(),
});
}
return;
}
self.entries.push(entry);
}
pub(crate) fn entry_for(&self, path: &Path) -> Option<&TrackedEntry> {
owning_entry(&self.entries, path)
}
pub(crate) fn entry_index_for(&self, path: &Path) -> Option<usize> {
owning_entry_index(&self.entries, path)
}
pub(crate) fn refuse_unusable_exclusions(&self) -> Result<()> {
match unusable_exclusions(&self.exclude_set()?) {
Some(report) => eyre::bail!(
"{report}, so nothing is saved, applied, or published; fix or remove the pattern, then try again"
),
None => Ok(()),
}
}
pub(crate) fn would_capture(&self, path: &Path) -> Result<bool> {
if !self.would_retain(path)? {
return Ok(false);
}
if let Ok(meta) = std::fs::symlink_metadata(path)
&& !meta.is_dir()
&& classify_file(&meta).is_err()
{
return Ok(false);
}
Ok(true)
}
pub(crate) fn would_retain(&self, path: &Path) -> Result<bool> {
let Some(owner) = self.entry_for(path) else {
return Ok(false);
};
if owner.capture_exclusion(path).is_some() {
return Ok(false);
}
if hard_exclusions().iter().any(|dir| path.starts_with(dir)) {
return Ok(false);
}
if path
.components()
.any(|component| component.as_os_str() == ".git")
{
return Ok(false);
}
Ok(!self.dropped(
&self.exclude_set()?,
owner,
path,
Asked::Exactly,
kind_of(path),
))
}
pub(crate) fn excluded_by_lists(
&self,
exclude: &ExcludeSet,
path: &Path,
asked: Asked,
) -> bool {
match self.entry_for(path) {
Some(owner) => self.dropped(exclude, owner, path, asked, kind_of(path)),
None => true,
}
}
fn dropped(
&self,
exclude: &ExcludeSet,
owner: &TrackedEntry,
path: &Path,
asked: Asked,
kind: Kind,
) -> bool {
if inside_nested_repository(owner, path) {
return true;
}
let judged = path != owner.path || kind == Kind::File;
if judged && exclude.is_match(path, &owner.path) {
return true;
}
if owner.is_excluded(path) {
return true;
}
match kind {
Kind::File => !owner.is_included(path),
Kind::Directory => owner.include_prunes(path),
Kind::Unknown => match asked {
Asked::Exactly => !owner.is_included(path),
Asked::Possibly => !owner.is_included(path) && owner.include_prunes(path),
},
}
}
pub(crate) fn exclude_set(&self) -> Result<ExcludeSet> {
ExcludeSet::new(&self.exclude)
}
pub(crate) fn walk(&self) -> Result<Walk> {
self.walk_entries(None)
}
pub(crate) fn walk_selected(&self, selected: &[usize]) -> Result<Walk> {
self.walk_entries(Some(selected))
}
fn walk_entries(&self, selected: Option<&[usize]>) -> Result<Walk> {
let set = self;
let exclude = set.exclude_set()?;
let hard = hard_exclusions();
let home = normalize(&dirs::HOME);
let mut walk = Walk {
manifest: set.manifest.clone(),
..Default::default()
};
walk.manifest.exclude = set.exclude.clone();
if let Some(report) = unusable_exclusions(&exclude) {
walk.warnings.push(format!(
"{report}; it is ignored here, so this lists paths it would leave out, and nothing is saved, applied, or published until it is fixed"
));
}
for (index, entry) in set.entries.iter().enumerate() {
if selected.is_some_and(|selected| !selected.contains(&index)) {
continue;
}
walk_entry(set, index, entry, &exclude, &hard, &mut walk);
}
walk.files.retain(|path, (index, policy)| {
let Some(owner) = set.entries.get(*index) else {
return capture_exclusion(path, policy).is_none();
};
let Some(reason) = owner.capture_exclusion(path) else {
if capture_exclusion(path, policy).is_some() {
walk.plaintext.push(PathReason {
path: display_path(path),
reason: "selected by an include list; saved in plaintext".into(),
});
}
return true;
};
walk.omitted.push(PathReason {
path: display_path(path),
reason: reason.into(),
});
false
});
for plaintext in &walk.plaintext {
walk.capture_warnings.push(format!(
"{}: an include list selects it, so it is saved in plaintext although it looks like a credential store; `encrypt = true` saves it encrypted instead",
plaintext.path
));
}
walk.entries = set.entries.clone();
let config = normalize(&global_config_dir());
let mut roots: BTreeMap<String, CaptureRoot> = BTreeMap::new();
for (path, (owner, _)) in &walk.files {
if path.to_str().is_none() {
eyre::bail!(
"history cannot represent a non-UTF-8 filename; refusing to change its bytes"
);
}
let (label, base, relative) = if let Ok(relative) = path.strip_prefix(&config) {
("config", config.clone(), relative.to_path_buf())
} else if let Ok(relative) = path.strip_prefix(&home) {
("home", home.clone(), relative.to_path_buf())
} else {
(
"fs",
PathBuf::from(std::path::MAIN_SEPARATOR.to_string()),
path.components()
.filter(|c| matches!(c, Component::Normal(_)))
.collect(),
)
};
let label = walk.entries[*owner]
.variant
.as_ref()
.map_or_else(|| label.to_string(), |variant| format!("{label}@{variant}"));
let root = roots.entry(label.clone()).or_insert_with(|| CaptureRoot {
label,
path: base,
files: vec![],
bytes: 0,
});
root.files.push(relative);
root.bytes += std::fs::symlink_metadata(path)
.ok()
.filter(|m| m.is_file())
.map_or(0, |m| m.len());
}
walk.roots = roots.into_values().collect();
Ok(walk)
}
pub(crate) fn coverage(&self, walk: &Walk) -> Coverage {
let entries: Vec<CoverageEntry> = walk
.entries
.iter()
.map(|entry| CoverageEntry {
path: entry.display(),
mode: entry.mode.clone(),
variant: entry.variant.clone(),
autosave: entry.policy.autosave,
encrypt: entry.policy.encrypt,
state: "live".into(),
declared_in: entry.declared_in.as_deref().map(display_path),
exclude: entry.exclude.clone(),
include: entry.include.clone(),
})
.collect();
let mut omitted = walk.omitted.clone();
omitted.extend(self.invalid.iter().cloned());
Coverage {
entries,
exclude: self.exclude.clone(),
matcher: Some(MATCHER_VERSION),
incomplete: walk.incomplete.clone(),
omitted,
nested: walk.nested.clone(),
}
}
}
fn walk_entry(
set: &TrackedSet,
index: usize,
entry: &TrackedEntry,
exclude: &ExcludeSet,
hard: &[PathBuf],
walk: &mut Walk,
) {
let home = normalize(&dirs::HOME);
let display = entry.display();
if is_refused_root(&entry.path, &home) {
walk.omitted.push(PathReason {
path: display,
reason: "refused: the home directory or above".into(),
});
return;
}
if hard.iter().any(|dir| entry.path.starts_with(dir)) {
walk.omitted.push(PathReason {
path: display,
reason: "mise internal directory".into(),
});
return;
}
let meta = match std::fs::symlink_metadata(&entry.path) {
Ok(meta) => meta,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
Err(err) => {
walk.omitted.push(PathReason {
path: display,
reason: format!("unreadable: {err}"),
});
return;
}
};
if !meta.is_dir() {
if exclude.is_match(&entry.path, &entry.path) {
return;
}
if !entry.is_included(&entry.path) {
walk.omitted.push(PathReason {
path: display,
reason: "its include list selects nothing: a list selects paths inside a tracked directory, and this entry is a file".into(),
});
return;
}
match classify_file(&meta) {
Ok(_) => {
walk.files.insert(entry.path.clone(), (index, entry.policy));
}
Err(reason) => walk.omitted.push(PathReason {
path: display,
reason,
}),
}
return;
}
let walker = WalkDir::new(&entry.path)
.follow_links(false)
.sort_by_file_name()
.into_iter()
.filter_entry(|candidate| {
!(candidate.file_type().is_dir()
&& (candidate.file_name() == ".git"
|| hard.iter().any(|dir| dir == candidate.path())))
});
let entry_exclude = entry.exclude_patterns();
let entry_include = entry.include_patterns();
let mut files = 0u64;
let mut bytes = 0u64;
let mut walker = walker;
while let Some(candidate) = walker.next() {
let candidate = match candidate {
Ok(candidate) => candidate,
Err(err) => {
let path = err
.path()
.map(display_path)
.unwrap_or_else(|| display.clone());
walk.omitted.push(PathReason {
path,
reason: format!("unreadable: {err}"),
});
continue;
}
};
let path = candidate.path();
if path == entry.path {
continue;
}
if set
.entry_index_for(path)
.is_some_and(|owner| owner != index)
{
if candidate.file_type().is_dir() {
walker.skip_current_dir();
}
continue;
}
let file_type = candidate.file_type();
if file_type.is_dir() && path.join(".git").exists() {
let reason = match entry.include_reaching_into(path) {
Some(pattern) => format!(
"{NESTED_REPOSITORY_REASON}; the include pattern {pattern:?} selects nothing inside it"
),
None => NESTED_REPOSITORY_REASON.to_string(),
};
walk.nested.push(PathReason {
path: display_path(path),
reason,
});
walker.skip_current_dir();
continue;
}
if file_type.is_dir() && exclude.prunes_directory(path, &entry.path) {
walker.skip_current_dir();
continue;
}
if exclude.is_match(path, &entry.path) {
continue;
}
if !entry_exclude.is_empty()
&& let Ok(rel) = path.strip_prefix(&entry.path)
&& crate::system::files::is_excluded(&pattern_relative(rel), &entry_exclude)
{
if file_type.is_dir() {
walker.skip_current_dir();
}
continue;
}
if file_type.is_dir() {
if entry.include_prunes(path) {
walk.skipped.insert(index);
walker.skip_current_dir();
}
continue;
}
if let Some(entry_include) = &entry_include {
*walk.considered.entry(index).or_default() += 1;
match path.strip_prefix(&entry.path) {
Ok(rel)
if !crate::system::files::is_excluded(
&pattern_relative(rel),
entry_include,
) =>
{
continue;
}
Err(_) => continue,
Ok(_) => {}
}
}
let meta = match candidate.metadata() {
Ok(meta) => meta,
Err(err) => {
walk.omitted.push(PathReason {
path: display_path(path),
reason: format!("unreadable: {err}"),
});
continue;
}
};
match classify_file(&meta) {
Ok(size) => {
files += 1;
bytes += size;
if files > MAX_FILES || bytes > MAX_BYTES {
let reason = format!(
"scan stopped after {MAX_FILES} files or {} MiB",
MAX_BYTES / (1024 * 1024)
);
walk.warnings
.push(format!("{display}: {reason}; the rest was not captured"));
walk.incomplete.push(PathReason {
path: display,
reason,
});
return;
}
walk.files.insert(path.to_path_buf(), (index, entry.policy));
}
Err(reason) => walk.omitted.push(PathReason {
path: display_path(path),
reason,
}),
}
}
}
fn classify_file(meta: &std::fs::Metadata) -> std::result::Result<u64, String> {
let file_type = meta.file_type();
if file_type.is_symlink() {
return Ok(0);
}
if !file_type.is_file() {
return Err("special file".into());
}
let size = meta.len();
if size > MAX_FILE_BYTES {
return Err(format!(
"{} MiB is over the {} MiB limit",
size / (1024 * 1024),
MAX_FILE_BYTES / (1024 * 1024)
));
}
Ok(size)
}
pub(crate) const CREDENTIAL_REASON: &str = "credential store; encrypt the file before tracking it";
pub(crate) const NESTED_REPOSITORY_REASON: &str =
"a separate Git repository; track it directly to capture its working files";
pub(crate) fn capture_exclusion(path: &Path, policy: &Policy) -> Option<&'static str> {
let name = path.file_name()?.to_str()?;
if name.ends_with(".local.toml") {
Some("machine-local configuration")
} else if !policy.encrypt && is_builtin_credential(path, name) {
Some(CREDENTIAL_REASON)
} else {
None
}
}
pub(crate) fn is_builtin_credential(path: &Path, name: &str) -> bool {
static NAMES: std::sync::LazyLock<GlobSet> = std::sync::LazyLock::new(credential_names);
static GLOBS: std::sync::LazyLock<GlobSet> =
std::sync::LazyLock::new(|| glob_set(CREDENTIAL_GLOBS));
GLOBS.is_match(name)
|| (path.starts_with(normalize(&global_config_dir())) && NAMES.is_match(name))
}
pub(crate) const OMISSION_LINES: usize = 10;
fn pattern_relative(rel: &Path) -> std::borrow::Cow<'_, Path> {
#[cfg(windows)]
{
std::borrow::Cow::Owned(PathBuf::from(rel.to_string_lossy().replace('\\', "/")))
}
#[cfg(not(windows))]
{
std::borrow::Cow::Borrowed(rel)
}
}
pub(crate) fn excluded_by_entry(entry_path: &Path, patterns: &[String], path: &Path) -> bool {
if patterns.is_empty() {
return false;
}
let patterns: Vec<glob::Pattern> = patterns
.iter()
.filter_map(|pattern| glob::Pattern::new(pattern).ok())
.collect();
match path.strip_prefix(entry_path) {
Ok(rel) if !rel.as_os_str().is_empty() => {
crate::system::files::is_excluded(&pattern_relative(rel), &patterns)
}
_ => false,
}
}
pub(crate) fn included_by_entry(entry_path: &Path, patterns: &[String], path: &Path) -> bool {
let patterns: Vec<glob::Pattern> = patterns
.iter()
.filter_map(|pattern| glob::Pattern::new(pattern).ok())
.collect();
match path.strip_prefix(entry_path) {
Ok(rel) if !rel.as_os_str().is_empty() => {
crate::system::files::is_excluded(&pattern_relative(rel), &patterns)
}
_ => false,
}
}
pub(crate) fn display_under(path: &str, root: &str) -> bool {
let path = display_separators(&file::replace_path(path).to_string_lossy());
let root = display_separators(&file::replace_path(root).to_string_lossy());
path == root
|| path
.strip_prefix(&root)
.is_some_and(|rest| rest.starts_with('/'))
}
fn display_separators(path: &str) -> String {
if cfg!(windows) {
path.replace('\\', "/")
} else {
path.to_string()
}
}
pub(crate) const LARGE_TREE_FILES: usize = 5_000;
pub(crate) const LARGE_TREE_BYTES: u64 = 256 * 1024 * 1024;
impl Walk {
pub(crate) fn file_count(&self) -> usize {
self.roots.iter().map(|root| root.files.len()).sum()
}
pub(crate) fn bytes(&self) -> u64 {
self.roots.iter().map(|root| root.bytes).sum()
}
pub(crate) fn summary(&self) -> String {
count_and_size(self.file_count(), self.bytes())
}
pub(crate) fn report_warnings(&self) {
for warning in self.warnings.iter().chain(&self.capture_warnings) {
super::notices::say(&format!("history: {warning}"));
}
}
}
#[derive(Debug, Default)]
pub(crate) struct EntryPreview {
pub files: usize,
pub bytes: u64,
pub omitted: Vec<PathReason>,
pub nested: Vec<PathReason>,
pub plaintext: Vec<PathReason>,
pub incomplete: Vec<PathReason>,
}
impl EntryPreview {
pub(crate) fn summary(&self) -> String {
count_and_size(self.files, self.bytes)
}
pub(crate) fn is_large(&self) -> bool {
self.files > LARGE_TREE_FILES || self.bytes > LARGE_TREE_BYTES
}
}
impl Walk {
pub(crate) fn preview_of(&self, set: &TrackedSet, index: usize) -> EntryPreview {
let mut preview = EntryPreview::default();
for (path, (owner, _)) in &self.files {
if *owner != index {
continue;
}
preview.files += 1;
preview.bytes += std::fs::symlink_metadata(path)
.ok()
.filter(|m| m.is_file())
.map_or(0, |m| m.len());
}
let owned = |reported: &PathReason| {
set.entry_index_for(&file::replace_path(Path::new(&reported.path))) == Some(index)
};
preview.omitted = self.omitted.iter().filter(|r| owned(r)).cloned().collect();
preview.nested = self.nested.iter().filter(|r| owned(r)).cloned().collect();
preview.plaintext = self
.plaintext
.iter()
.filter(|r| owned(r))
.cloned()
.collect();
let display = set.entries[index].display();
preview.incomplete = self
.incomplete
.iter()
.filter(|r| r.path == display)
.cloned()
.collect();
preview
}
}
pub(crate) fn count_and_size(files: usize, bytes: u64) -> String {
format!(
"{} {}, {}",
with_separators(files),
if files == 1 { "file" } else { "files" },
bytesize::ByteSize::b(bytes).display().iec()
)
}
pub(crate) fn with_separators(n: usize) -> String {
let digits = n.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(ch);
}
out
}
pub(crate) fn preview_set(path: &Path, policy: Policy) -> Result<TrackedSet> {
Ok(preview_set_with(
path,
policy,
super::config::exclude_globs()?,
))
}
pub(crate) fn preview_set_with(path: &Path, policy: Policy, exclude: Vec<String>) -> TrackedSet {
let mut set = TrackedSet {
exclude,
..Default::default()
};
set.push(TrackedEntry::new(normalize_target(path), "track", policy));
set
}
pub(crate) fn omission_report(omitted: &[PathReason], nested: &[PathReason]) -> Vec<String> {
if omitted.is_empty() && nested.is_empty() {
vec![]
} else if omitted.len() + nested.len() <= OMISSION_LINES {
omitted
.iter()
.map(|omitted| format!("omitted: {} ({})", omitted.path, omitted.reason))
.chain(
nested
.iter()
.map(|nested| format!("nested: {} ({})", nested.path, nested.reason)),
)
.collect()
} else {
vec![omission_summary(omitted, nested)]
}
}
pub(crate) fn omission_summary(omitted: &[PathReason], nested: &[PathReason]) -> String {
let mut parts = vec![];
if !omitted.is_empty() {
let credentials = omitted
.iter()
.filter(|omitted| omitted.reason == CREDENTIAL_REASON)
.count();
let detail = match credentials {
0 => String::new(),
n if n == omitted.len() => " (credential store)".into(),
n => format!(" ({n} credential store)"),
};
parts.push(format!(
"{} files omitted from capture{detail}",
omitted.len()
));
}
if !nested.is_empty() {
parts.push(format!(
"{} nested {} skipped",
nested.len(),
if nested.len() == 1 {
"repository"
} else {
"repositories"
}
));
}
format!("{}; `mise dot paths` lists them", parts.join("; "))
}
fn credential_names() -> GlobSet {
glob_set(CREDENTIAL_NAMES)
}
fn glob_set(patterns: &[&str]) -> GlobSet {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
if let Ok(glob) = Glob::new(pattern) {
builder.add(glob);
}
}
builder.build().expect("static credential globs")
}
#[derive(Debug, Default)]
pub(crate) struct ExcludeSet {
list: PatternList,
}
impl ExcludeSet {
pub(crate) fn new(globs: &[String]) -> Result<Self> {
Ok(Self {
list: PatternList::new(globs)?,
})
}
pub(crate) fn unusable(&self) -> &[(String, String)] {
&self.list.unusable
}
pub(crate) fn is_match(&self, path: &Path, root: &Path) -> bool {
self.decide(path, root, false)
}
pub(crate) fn prunes_directory(&self, dir: &Path, root: &Path) -> bool {
self.decide(dir, root, true) && !self.may_reinclude_below(dir)
}
fn decide(&self, path: &Path, root: &Path, as_directory: bool) -> bool {
if self.list.rules.is_empty() {
return false;
}
let relative_path = if path == root {
path.file_name().map(PathBuf::from)
} else {
path.strip_prefix(root).map(Path::to_path_buf).ok()
};
let mut candidates = vec![];
for ancestor in path.ancestors() {
candidates.push(separators(ancestor));
if ancestor == root {
break;
}
}
let relative: Vec<String> = relative_path
.iter()
.flat_map(|relative| relative.ancestors().collect::<Vec<_>>())
.filter(|ancestor| !ancestor.as_os_str().is_empty())
.map(separators)
.collect();
let components: Vec<&str> = relative
.first()
.map(|relative| {
relative
.split('/')
.filter(|component| !component.is_empty())
.collect()
})
.unwrap_or_default();
self.list
.rules
.iter()
.rfind(|rule| {
if as_directory {
rule.covers_directory(&candidates, &relative, &components)
} else {
rule.matches_any(&candidates, &relative, &components)
}
})
.is_some_and(|rule| !rule.negated)
}
pub(crate) fn may_reinclude_below(&self, dir: &Path) -> bool {
let dir = separators(dir);
self.list
.rules
.iter()
.filter(|rule| rule.negated)
.any(|rule| {
if rule.anchor != Anchor::Absolute || rule.reinclude_roots.is_empty() {
return true;
}
rule.reinclude_roots
.iter()
.any(|root| under_or_above(root, &dir))
})
}
}
fn declared_exclude(request: &crate::system::files::FileRequest) -> Option<Vec<String>> {
request.policy.explicit.exclude.then(|| {
request
.exclude
.iter()
.map(|pattern| pattern.as_str().to_owned())
.collect()
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Asked {
Exactly,
Possibly,
}
#[derive(Debug, Default)]
pub(crate) struct PatternList {
rules: Vec<PatternRule>,
unusable: Vec<(String, String)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Anchor {
Name,
Absolute,
Relative,
}
#[derive(Debug)]
struct PatternRule {
matchers: Vec<globset::GlobMatcher>,
directory_matchers: Vec<globset::GlobMatcher>,
reinclude_roots: Vec<String>,
negated: bool,
anchor: Anchor,
}
impl PatternRule {
fn compile(body: &str, negated: bool) -> Result<Self> {
let anchor = anchor_of(body);
let globs = if anchor == Anchor::Name {
vec![body.to_string()]
} else {
anchored_globs(body)
};
let compile = |glob: &str| -> Result<globset::GlobMatcher> {
Ok(build_glob(glob, anchor)?.compile_matcher())
};
let mut matchers = vec![];
let mut directory_matchers = vec![];
for glob in &globs {
matchers.push(compile(glob)?);
if let Some(directory) = glob.strip_suffix("/**")
&& !directory.is_empty()
{
directory_matchers.push(compile(directory)?);
}
}
let reinclude_roots = if negated && anchor == Anchor::Absolute {
globs.iter().filter_map(|glob| literal_root(glob)).collect()
} else {
vec![]
};
Ok(Self {
matchers,
directory_matchers,
reinclude_roots,
negated,
anchor,
})
}
fn matches_any(&self, candidates: &[String], relative: &[String], components: &[&str]) -> bool {
match self.anchor {
Anchor::Absolute => candidates.iter().any(|c| self.is_glob_match(c)),
Anchor::Relative => relative.iter().any(|r| self.is_glob_match(r)),
Anchor::Name => components.iter().any(|c| self.is_glob_match(c)),
}
}
fn is_glob_match(&self, candidate: &str) -> bool {
self.matchers
.iter()
.any(|matcher| matcher.is_match(Path::new(candidate)))
}
fn covers_directory(
&self,
candidates: &[String],
relative: &[String],
components: &[&str],
) -> bool {
if self.matches_any(candidates, relative, components) {
return true;
}
let against: &[String] = match self.anchor {
Anchor::Absolute => candidates,
Anchor::Relative => relative,
Anchor::Name => return false,
};
against.iter().any(|candidate| {
self.directory_matchers
.iter()
.any(|matcher| matcher.is_match(Path::new(candidate)))
})
}
}
fn path_components(rel: &Path) -> Vec<String> {
rel.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect()
}
fn reaches_into(pattern: &str, components: &[String]) -> bool {
let pattern = display_separators(pattern);
if !pattern.contains('/') {
return true;
}
let parts: Vec<&str> = pattern.split('/').filter(|part| !part.is_empty()).collect();
let mut parts = parts.as_slice();
let mut rest = components;
loop {
match (parts.first(), rest.first()) {
(Some(&"**"), _) => return true,
(Some(_), None) | (None, _) => return true,
(Some(part), Some(component)) => {
let matches = glob::Pattern::new(part)
.map(|glob| glob.matches(component))
.unwrap_or(true);
if !matches {
return false;
}
parts = &parts[1..];
rest = &rest[1..];
}
}
}
}
fn build_glob(glob: &str, anchor: Anchor) -> std::result::Result<Glob, globset::Error> {
if anchor == Anchor::Name {
Glob::new(glob)
} else {
globset::GlobBuilder::new(glob)
.literal_separator(true)
.build()
}
}
fn anchor_of(body: &str) -> Anchor {
if !is_path_anchored(body) {
Anchor::Name
} else if file::replace_path(Path::new(body)).is_absolute() {
Anchor::Absolute
} else {
Anchor::Relative
}
}
pub(crate) fn unusable_pattern(body: &str) -> Option<String> {
if body.contains('$') {
return Some(
"environment variables are not supported in exclusion patterns; write `~/…` or an absolute path".into(),
);
}
let anchor = anchor_of(body);
let probes = if anchor == Anchor::Name {
vec![body.to_string()]
} else {
anchored_globs(body)
};
probes
.iter()
.find_map(|probe| build_glob(probe, anchor).err().map(|err| err.to_string()))
}
fn is_path_anchored(body: &str) -> bool {
let body = &file::replace_path(Path::new(body))
.to_string_lossy()
.into_owned();
body.contains('/') || (cfg!(windows) && body.contains('\\'))
}
fn anchored_globs(body: &str) -> Vec<String> {
let expanded = file::replace_path(Path::new(body));
let expanded = expanded.as_path();
if !expanded.is_absolute() {
let text = separators(
&expanded
.components()
.filter(|component| !matches!(component, std::path::Component::CurDir))
.collect::<PathBuf>(),
);
return vec![if text.starts_with("**/") {
text
} else {
format!("**/{text}")
}];
}
let written = separators(expanded);
let normalized = separators(&normalize_target(expanded));
if normalized == written {
vec![written]
} else {
vec![written, normalized]
}
}
fn literal_root(glob: &str) -> Option<String> {
let literal = match glob.find(['*', '?', '[', '{']) {
Some(index) => &glob[..index],
None => glob,
};
let root = literal.rsplit_once('/').map(|(root, _)| root)?;
(!root.is_empty()).then(|| root.to_string())
}
fn under_or_above(one: &str, other: &str) -> bool {
let (shorter, longer) = if one.len() <= other.len() {
(one, other)
} else {
(other, one)
};
longer == shorter
|| longer
.strip_prefix(shorter)
.is_some_and(|rest| rest.starts_with('/'))
}
fn separators(path: &Path) -> String {
let text = path.to_string_lossy().into_owned();
if cfg!(windows) {
text.replace('\\', "/")
} else {
text
}
}
impl PatternList {
pub(crate) fn new(patterns: &[String]) -> Result<Self> {
let mut rules = vec![];
let mut unusable = vec![];
for pattern in patterns {
let (body, negated) = match pattern.strip_prefix('!') {
Some(rest) => (rest, true),
None => (pattern.as_str(), false),
};
if let Some(reason) = unusable_pattern(body) {
unusable.push((pattern.clone(), reason));
continue;
}
match PatternRule::compile(body, negated) {
Ok(rule) => rules.push(rule),
Err(err) => unusable.push((pattern.clone(), err.to_string())),
}
}
Ok(Self { rules, unusable })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
File,
Directory,
Unknown,
}
fn kind_of(path: &Path) -> Kind {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => Kind::Directory,
Ok(_) => Kind::File,
Err(_) => Kind::Unknown,
}
}
fn inside_nested_repository(owner: &TrackedEntry, path: &Path) -> bool {
path.ancestors()
.take_while(|ancestor| ancestor.starts_with(&owner.path) && *ancestor != owner.path)
.any(|ancestor| ancestor.join(".git").exists())
}
fn unusable_exclusions(exclude: &ExcludeSet) -> Option<String> {
let unusable = exclude.unusable();
if unusable.is_empty() {
return None;
}
let sources: Vec<String> = unusable
.iter()
.map(|(pattern, reason)| {
let files = super::config::exclusion_sources(pattern);
match files.is_empty() {
true => format!("{pattern:?}: {reason}"),
false => format!(
"{pattern:?} in {}: {reason}",
files
.iter()
.map(display_path)
.collect::<Vec<_>>()
.join(", ")
),
}
})
.collect();
Some(format!(
"[history] exclude cannot be applied: {}",
sources.join("; ")
))
}
pub(crate) const MATCHER_VERSION: u32 = 1;
pub(crate) fn hard_exclusions() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = [
*dirs::STATE,
*dirs::CACHE,
*dirs::DATA,
*dirs::INSTALLS,
*dirs::DOWNLOADS,
*dirs::PLUGINS,
]
.into_iter()
.map(normalize)
.collect();
dirs.push(normalize(&super::store::store_dir_in(&dirs::STATE)));
dirs.push(normalize(&global_config_dir()).join(".mise-history"));
dirs.extend(
crate::agecrypt::identity_paths()
.iter()
.map(|path| normalize(path)),
);
dirs.sort();
dirs.dedup();
dirs
}
pub(crate) fn global_config_dir() -> PathBuf {
crate::env::MISE_GLOBAL_CONFIG_FILE
.as_deref()
.map(|path| {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
})
.unwrap_or_else(|| dirs::CONFIG.to_path_buf())
}
pub(crate) fn normalize(path: &Path) -> PathBuf {
let expanded = file::replace_path(path);
dunce::canonicalize(&expanded).unwrap_or_else(|_| lexical(&expanded))
}
pub(crate) fn normalize_target(path: &Path) -> PathBuf {
let expanded = file::replace_path(path);
if !file::is_symlink_or_junction(&expanded)
&& let Ok(resolved) = dunce::canonicalize(&expanded)
{
return lexical(&resolved);
}
let mut tail = Vec::new();
let mut ancestor = expanded.as_path();
if let (Some(parent), Some(name)) = (ancestor.parent(), ancestor.file_name()) {
tail.push(name.to_os_string());
ancestor = parent;
}
loop {
let candidate = if ancestor.as_os_str().is_empty() {
Path::new(".")
} else {
ancestor
};
if let Ok(mut resolved) = dunce::canonicalize(candidate) {
for component in tail.iter().rev() {
resolved.push(component);
}
return lexical(&resolved);
}
let (Some(parent), Some(name)) = (ancestor.parent(), ancestor.file_name()) else {
return lexical(&expanded);
};
tail.push(name.to_os_string());
ancestor = parent;
}
}
pub(crate) fn is_refused_root(path: &Path, home: &Path) -> bool {
path == home || home.starts_with(path) || path.parent().is_none()
}
fn lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
pub(crate) fn tree_path_to_display(tree_path: &str) -> String {
let (stem, rest) = tree_path.split_once('/').unwrap_or((tree_path, ""));
let root = stem.split('@').next().unwrap_or(stem);
if root == "config" {
display_path(global_config_dir().join(rest))
} else if root == "home" {
format!("~/{rest}")
} else if let Some(rest) = tree_path.strip_prefix("fs/") {
format!("/{rest}")
} else {
tree_path.to_string()
}
}
pub(crate) fn owning_entry<'a>(
entries: &'a [TrackedEntry],
path: &Path,
) -> Option<&'a TrackedEntry> {
owning_entry_index(entries, path).map(|index| &entries[index])
}
pub(crate) fn owning_entry_index(entries: &[TrackedEntry], path: &Path) -> Option<usize> {
entries
.iter()
.enumerate()
.filter(|(_, entry)| path.starts_with(&entry.path))
.max_by_key(|(_, entry)| entry.path.components().count())
.map(|(index, _)| index)
}
pub(crate) fn owning_display<'a, T: 'a>(
items: impl IntoIterator<Item = &'a T>,
path: &str,
key: impl Fn(&T) -> &str,
) -> Option<&'a T> {
items
.into_iter()
.filter(|item| display_under(path, key(item)))
.max_by_key(|item| display_depth(key(item)))
}
fn display_depth(path: &str) -> usize {
display_separators(path)
.split('/')
.filter(|component| !component.is_empty() && *component != ".")
.count()
}
pub(crate) fn governing_key(
roots: &super::sync::layout::Roots,
entries: &[TrackedEntry],
path: &Path,
) -> Option<String> {
let owner = owning_entry(entries, path);
match owner {
Some(entry) => roots.branch_path(path, entry.variant.as_deref()),
None if entries
.iter()
.any(|entry| entry.path.starts_with(path) && entry.path != path) =>
{
roots.branch_path(path, None)
}
None => None,
}
}
pub(crate) fn mode_from(
roots: &super::sync::layout::Roots,
entries: &[TrackedEntry],
permissions: &BTreeMap<String, u32>,
path: &Path,
) -> Option<u32> {
let key = governing_key(roots, entries, path)?;
Some(permissions.get(&key).copied().unwrap_or(0o755))
}
pub(crate) fn ensure_portable_ancestors(path: &Path) -> Result<()> {
eyre::ensure!(
path.to_str().is_some(),
"tracking does not support non-UTF-8 filenames"
);
let roots = super::sync::layout::Roots::current();
let bases = [
dirs::HOME.to_path_buf(),
global_config_dir(),
roots.home,
roots.config_dir,
];
let relative = |base: &Path| {
let mut components = path.components();
for expected in base.components() {
let actual = components.next()?;
if actual != expected
&& !(cfg!(windows)
&& actual
.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case(&expected.as_os_str().to_string_lossy()))
{
return None;
}
}
Some(components.collect::<PathBuf>())
};
let Some((base, rest)) = bases
.iter()
.filter_map(|base| relative(base).map(|rest| (base, rest)))
.max_by_key(|(base, _)| base.components().count())
else {
eyre::bail!(
"tracking requires a portable path under home or the mise configuration directory"
);
};
let mut ancestor = base.clone();
for component in rest
.components()
.take(rest.components().count().saturating_sub(1))
{
ancestor.push(component);
if file::is_symlink_or_junction(&ancestor) {
eyre::bail!(
"cannot track {} through symlinked parent {}; explicitly track the link itself and its real target instead",
display_path(path),
display_path(&ancestor)
);
}
}
Ok(())
}
pub(crate) fn display_to_tree_path(path: &str) -> String {
let expanded = normalize_target(Path::new(path));
let config = normalize(&global_config_dir());
if let Ok(relative) = expanded.strip_prefix(config) {
return format!("config/{}", relative.to_string_lossy().replace('\\', "/"))
.trim_end_matches('/')
.to_owned();
}
let home = normalize(&dirs::HOME);
match expanded.strip_prefix(&home) {
Ok(rel) if !rel.as_os_str().is_empty() => {
format!("home/{}", rel.to_string_lossy().replace('\\', "/"))
}
Ok(_) => "home".to_string(),
Err(_) => {
let rel: Vec<String> = expanded
.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().to_string()),
_ => None,
})
.collect();
format!("fs/{}", rel.join("/"))
}
}
}
#[cfg(test)]
mod tests {
const ROOT: &str = "/nonexistent-mise-test";
use super::*;
#[cfg(unix)]
#[test]
fn enrollment_rejects_alias_parents_but_allows_links_and_real_targets() -> Result<()> {
let roots = super::super::sync::layout::Roots::current();
let temp = tempfile::tempdir_in(&roots.home)?;
let real = temp.path().join("real");
let alias = temp.path().join("alias");
std::fs::create_dir(&real)?;
std::fs::write(real.join("config"), "native")?;
std::os::unix::fs::symlink("real", &alias)?;
assert!(ensure_portable_ancestors(&alias).is_ok());
assert!(ensure_portable_ancestors(&real.join("config")).is_ok());
assert!(ensure_portable_ancestors(&alias.join("config")).is_err());
assert!(ensure_portable_ancestors(&alias.join("missing/child")).is_err());
let manifest = super::super::manifest::Manifest {
enrollment: vec![super::super::manifest::Enrollment {
path: roots.branch_path(&alias.join("config"), None).unwrap(),
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
}],
..Default::default()
};
assert!(manifest.tracking().is_err());
Ok(())
}
#[cfg(unix)]
#[test]
fn missing_descendants_keep_their_resolved_identity() {
let temp = tempfile::tempdir().unwrap();
let real = temp.path().join("real");
std::fs::create_dir_all(real.join("nested")).unwrap();
let alias = temp.path().join("alias");
std::os::unix::fs::symlink(&real, &alias).unwrap();
let path = alias.join("nested/file");
std::fs::write(&path, "contents").unwrap();
let before = normalize_target(&path);
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(real.join("nested")).unwrap();
assert_eq!(normalize_target(&path), before);
assert_eq!(
normalize_target(&alias),
normalize(temp.path()).join("alias")
);
}
#[test]
fn a_selected_walk_visits_only_what_was_asked_for() {
let tmp = tempfile::tempdir().unwrap();
let other = tmp.path().join("other");
let target = tmp.path().join("target");
std::fs::create_dir_all(other.join("deep")).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(other.join("deep/one.toml"), "other").unwrap();
std::fs::write(target.join("two.toml"), "target").unwrap();
let mut set = TrackedSet::default();
set.push(entry(&other));
set.push(entry(&target));
let index = set.entry_index_for(&target).unwrap();
let all = set.walk().unwrap();
assert!(all.files.contains_key(&other.join("deep/one.toml")));
assert!(all.files.contains_key(&target.join("two.toml")));
let selected = set.walk_selected(&[index]).unwrap();
assert!(
!selected.files.contains_key(&other.join("deep/one.toml")),
"an entry nobody asked about was walked"
);
assert!(selected.files.contains_key(&target.join("two.toml")));
assert_eq!(
selected.preview_of(&set, index).files,
all.preview_of(&set, index).files
);
}
#[test]
fn an_entry_inside_another_is_not_walked_twice() {
let tmp = tempfile::tempdir().unwrap();
let outer = tmp.path().join("config");
let inner = outer.join("nvim");
std::fs::create_dir_all(inner.join("lua")).unwrap();
std::fs::write(outer.join("outer.toml"), "outer").unwrap();
for i in 0..20 {
std::fs::write(inner.join(format!("lua/{i}.lua")), "inner").unwrap();
}
let mut set = TrackedSet::default();
set.push(entry(&outer));
set.push(entry(&inner));
let outer_index = set.entry_index_for(&outer).unwrap();
let inner_index = set.entry_index_for(&inner).unwrap();
let walk = set.walk().unwrap();
assert_eq!(walk.files[&outer.join("outer.toml")].0, outer_index);
assert_eq!(walk.files[&inner.join("lua/3.lua")].0, inner_index);
assert_eq!(walk.files.len(), 21);
let selected = set.walk_selected(&[outer_index]).unwrap();
assert_eq!(
selected.files.keys().collect::<Vec<_>>(),
vec![&outer.join("outer.toml")]
);
}
#[test]
fn an_entry_list_matches_a_path_with_the_host_separator() {
let root = PathBuf::from(if cfg!(windows) {
"C:\\Users\\me\\.codex"
} else {
"/home/me/.codex"
});
let patterns = ["cache/**".to_string()];
assert!(excluded_by_entry(
&root,
&patterns,
&root.join("cache").join("index"),
));
assert!(!excluded_by_entry(
&root,
&patterns,
&root.join("config.toml"),
));
#[cfg(unix)]
assert!(!excluded_by_entry(
&root,
&patterns,
&root.join("cache\\index"),
));
}
#[test]
fn every_pattern_the_cli_accepts_compiles() {
for body in [
"cache",
"*.log",
"!*.log",
"sessions/**",
"~/.codex/sessions/**",
"./rules/*.md",
"a[bc]d",
"**/node_modules",
"{a,b}/**",
] {
let negated = body.starts_with('!');
let rule = body.strip_prefix('!').unwrap_or(body);
assert_eq!(
unusable_pattern(rule).is_none(),
PatternRule::compile(rule, negated).is_ok(),
"the CLI and the matcher disagree about {body:?}"
);
}
assert!(unusable_pattern("rules/[unclosed/**").is_some());
assert!(PatternRule::compile("rules/[unclosed/**", false).is_err());
}
#[test]
fn replay_versions_only_the_patterns_that_can_affect_a_path() {
use crate::system::history::replay::{PathState, classify_coverage};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("plain");
let other = tmp.path().join("filtered");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&other).unwrap();
let mut set = TrackedSet::default();
set.push(entry(&root));
let mut filtered = entry(&other);
filtered.exclude = Some(vec!["private/**".into()]);
set.push(filtered);
let mut coverage = set.coverage(&set.walk().unwrap());
coverage.matcher = Some(MATCHER_VERSION + 1);
let display = display_path(root.join("new.txt"));
assert!(matches!(
classify_coverage(&coverage, &display),
PathState::Absent
));
let index = coverage
.entries
.iter()
.position(|entry| entry.path == display_path(&root))
.unwrap();
coverage.entries[index].exclude = Some(vec![]);
assert!(matches!(
classify_coverage(&coverage, &display),
PathState::Absent
));
coverage.entries[index].exclude = Some(vec!["private/**".into()]);
assert!(matches!(
classify_coverage(&coverage, &display),
PathState::Unevaluable(_)
));
}
#[cfg(unix)]
#[test]
fn replay_skips_exclusions_made_unusable_by_a_changed_symlink() {
use crate::system::history::replay::{PathState, classify_coverage};
let tmp = tempfile::tempdir().unwrap();
let temp_root = tmp.path().canonicalize().unwrap();
let root = temp_root.join("real");
let bad = temp_root.join("invalid[");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(&bad).unwrap();
let local = root.join("private.txt");
std::fs::write(&local, "keep locally").unwrap();
let link = temp_root.join("link");
std::os::unix::fs::symlink(&root, &link).unwrap();
let mut set = TrackedSet {
exclude: vec![format!("{}/**", link.display())],
..Default::default()
};
set.push(entry(&root));
assert!(set.exclude_set().unwrap().unusable().is_empty());
let walk = set.walk().unwrap();
assert!(!walk.files.contains_key(&local));
let coverage = set.coverage(&walk);
assert!(matches!(
classify_coverage(&coverage, &display_path(&local)),
PathState::Uncovered
));
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&bad, &link).unwrap();
assert!(!set.exclude_set().unwrap().unusable().is_empty());
let state = classify_coverage(&coverage, &display_path(&local));
assert!(matches!(state, PathState::Unevaluable(_)));
assert!(state.skip_reason("0123456789").is_some());
}
#[cfg(unix)]
#[test]
fn a_pattern_is_refused_when_any_form_it_compiles_to_is_unusable() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("real")).unwrap();
let link = tmp.path().join("link[x");
std::os::unix::fs::symlink(tmp.path().join("real"), &link).unwrap();
let body = format!("{}/**", link.display());
let forms = anchored_globs(&body);
assert_eq!(forms.len(), 2, "expected two forms, got {forms:?}");
assert!(
Glob::new(&forms[1]).is_ok(),
"the normalized form is the usable one: {forms:?}"
);
assert!(
unusable_pattern(&body).is_some(),
"the written form is unusable, so the pattern is refused: {forms:?}"
);
}
#[test]
fn the_owner_of_a_recorded_path_is_the_same_on_either_host() {
struct Recorded(&'static str);
for (entries, path, expected) in [
(
vec![Recorded("~/.config"), Recorded("~/.config/mise")],
"~/.config/mise/config.toml",
"~/.config/mise",
),
#[cfg(windows)]
(
vec![Recorded("~\\.config"), Recorded("~\\.config\\mise")],
"~\\.config\\mise\\config.toml",
"~\\.config\\mise",
),
#[cfg(windows)]
(
vec![Recorded("~/.config"), Recorded("~\\.config\\mise")],
"~/.config/mise/config.toml",
"~\\.config\\mise",
),
#[cfg(unix)]
(
vec![Recorded("~/.config"), Recorded("~/.config\\mise")],
"~/.config/mise/config.toml",
"~/.config",
),
] {
let owner = owning_display(&entries, path, |entry| entry.0);
assert_eq!(
owner.map(|entry| entry.0),
Some(expected),
"{path} under {:?}",
entries.iter().map(|entry| entry.0).collect::<Vec<_>>()
);
}
}
#[test]
fn a_global_pattern_matching_an_entrys_own_name_does_not_exclude_it() {
let tmp = tempfile::tempdir().unwrap();
let directory = tmp.path().join("cache");
std::fs::create_dir_all(&directory).unwrap();
std::fs::write(directory.join("kept.toml"), "keep").unwrap();
let file = tmp.path().join("notes.md");
std::fs::write(&file, "keep").unwrap();
let mut set = TrackedSet {
exclude: vec!["cache".to_string(), "notes.md".to_string()],
..Default::default()
};
set.push(entry(&directory));
set.push(entry(&file));
let exclude = set.exclude_set().unwrap();
assert!(!set.excluded_by_lists(&exclude, &directory, Asked::Possibly));
assert!(set.would_retain(&directory).unwrap());
assert!(
set.walk()
.unwrap()
.files
.contains_key(&directory.join("kept.toml"))
);
assert!(set.excluded_by_lists(&exclude, &file, Asked::Possibly));
assert!(!set.would_retain(&file).unwrap());
assert!(!set.walk().unwrap().files.contains_key(&file));
std::fs::remove_dir_all(&directory).unwrap();
std::fs::remove_file(&file).unwrap();
assert!(
!set.excluded_by_lists(&exclude, &directory, Asked::Possibly),
"the removal of a tracked directory was ignored"
);
assert!(
!set.excluded_by_lists(&exclude, &file, Asked::Possibly),
"the removal of a tracked file was ignored"
);
}
#[test]
fn an_include_list_on_a_file_entry_selects_nothing() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("config.toml");
std::fs::write(&file, "keep").unwrap();
for include in [None, Some(vec![]), Some(vec!["config.toml".to_string()])] {
let declared = include.is_some();
let mut tracked = entry(&file);
tracked.include = include.clone();
let mut set = TrackedSet::default();
set.push(tracked);
let walk = set.walk().unwrap();
assert_eq!(
walk.files.contains_key(&file),
!declared,
"capture with include = {include:?}"
);
assert_eq!(
set.would_retain(&file).unwrap(),
!declared,
"would_retain with include = {include:?}"
);
assert_eq!(
!set.excluded_by_lists(&set.exclude_set().unwrap(), &file, Asked::Possibly),
!declared,
"watcher with include = {include:?}"
);
assert!(
!included_by_entry(&file, include.as_deref().unwrap_or_default(), &file),
"replay with include = {include:?}"
);
assert_eq!(
walk.omitted
.iter()
.any(|omitted| omitted.reason.contains("selects nothing")),
declared,
"reported with include = {include:?}"
);
}
}
#[test]
fn a_directory_is_covered_by_what_its_include_list_selects_below_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("rules/deep")).unwrap();
std::fs::create_dir_all(root.join("sessions")).unwrap();
std::fs::write(root.join("rules/one.md"), "keep").unwrap();
std::fs::write(root.join("sessions/a.jsonl"), "drop").unwrap();
let mut tracked = entry(&root);
tracked.include = Some(vec!["rules/**".to_string()]);
let mut set = TrackedSet::default();
set.push(tracked);
for directory in [root.clone(), root.join("rules"), root.join("rules/deep")] {
assert!(
set.would_capture(&directory).unwrap(),
"would_capture {}",
directory.display()
);
assert!(
set.would_retain(&directory).unwrap(),
"would_retain {}",
directory.display()
);
}
assert!(!set.would_capture(&root.join("sessions")).unwrap());
assert!(set.would_retain(&root.join("rules/one.md")).unwrap());
assert!(!set.would_retain(&root.join("sessions/a.jsonl")).unwrap());
let unselected = root.join("sessions/a.jsonl");
assert!(!set.would_retain(&unselected).unwrap());
let exclude = set.exclude_set().unwrap();
std::fs::remove_dir_all(root.join("rules")).unwrap();
assert!(!set.excluded_by_lists(&exclude, &root.join("rules"), Asked::Possibly));
}
#[test]
fn a_list_on_a_credential_file_entry_does_not_lift_its_guard() {
let tmp = tempfile::tempdir().unwrap();
let key = tmp.path().join("id_rsa");
std::fs::write(&key, "key").unwrap();
for include in [
None,
Some(vec![]),
Some(vec!["id_rsa".to_string()]),
Some(vec!["**".to_string()]),
] {
let mut tracked = entry(&key);
tracked.include = include.clone();
assert_eq!(
tracked.capture_exclusion(&key),
Some(CREDENTIAL_REASON),
"include = {include:?} lifted the guard on the entry itself"
);
}
let dir = tmp.path().join("ssh");
std::fs::create_dir(&dir).unwrap();
let inside = dir.join("id_rsa");
std::fs::write(&inside, "key").unwrap();
let mut tracked = entry(&dir);
assert_eq!(tracked.capture_exclusion(&inside), Some(CREDENTIAL_REASON));
tracked.include = Some(vec!["id_rsa".to_string()]);
assert_eq!(tracked.capture_exclusion(&inside), None);
}
#[test]
fn a_nested_repository_is_skipped_and_the_pattern_that_reached_in_is_named() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("hammerspoon");
let plugin = root.join("Spoons/Sky.spoon");
std::fs::create_dir_all(plugin.join(".git")).unwrap();
std::fs::write(root.join("init.lua"), "keep").unwrap();
std::fs::write(plugin.join("init.lua"), "theirs").unwrap();
for (patterns, named) in [
(vec!["Spoons/*"], true),
(vec!["**/init.lua"], true),
(vec!["init.lua"], true),
(vec!["Spoons/Sky.spoon/**"], true),
] {
let mut tracked = entry(&root);
tracked.include = Some(patterns.iter().map(|p| (*p).to_string()).collect());
let mut set = TrackedSet::default();
set.push(tracked);
let walk = set.walk().unwrap();
assert!(
!walk.files.contains_key(&plugin.join("init.lua")),
"{patterns:?} captured a file inside a nested repository"
);
let reported = walk
.nested
.iter()
.find(|nested| nested.path.ends_with("Sky.spoon"))
.unwrap_or_else(|| panic!("{patterns:?}: the repository was not reported"));
assert_eq!(
reported.reason.contains("selects nothing inside it"),
named,
"{patterns:?}: {}",
reported.reason
);
}
}
#[test]
fn a_narrow_include_list_does_not_walk_what_it_leaves_out() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("rules")).unwrap();
std::fs::create_dir_all(root.join("sessions/deep")).unwrap();
std::fs::write(root.join("rules/one.md"), "keep").unwrap();
for i in 0..40 {
std::fs::write(root.join(format!("sessions/{i}.jsonl")), "noise").unwrap();
std::fs::write(root.join(format!("sessions/deep/{i}.jsonl")), "noise").unwrap();
}
let mut tracked = entry(&root);
tracked.include = Some(vec!["rules/**".to_string()]);
let mut set = TrackedSet::default();
set.push(tracked);
let index = set.entry_index_for(&root).unwrap();
let walk = set.walk().unwrap();
assert_eq!(
walk.files.keys().collect::<Vec<_>>(),
vec![&root.join("rules/one.md")]
);
assert!(
walk.considered.get(&index).copied().unwrap_or(0) <= 2,
"the walk descended into what the list leaves out: {:?}",
walk.considered
);
assert!(walk.incomplete.is_empty(), "{:?}", walk.incomplete);
}
#[test]
fn a_directory_whose_children_are_selected_is_watched() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("rules")).unwrap();
std::fs::write(root.join("rules/one.md"), "keep").unwrap();
std::fs::write(root.join("notes.md"), "noise").unwrap();
let mut tracked = entry(&root);
tracked.include = Some(vec!["rules/**".to_string()]);
let mut set = TrackedSet::default();
set.push(tracked);
let exclude = set.exclude_set().unwrap();
let exclude_anchored = set.exclude_set().unwrap();
for directory in [root.clone(), root.join("rules")] {
assert!(
!set.excluded_by_lists(&exclude, &directory, Asked::Possibly),
"the watcher ignored {}",
directory.display()
);
}
std::fs::create_dir(root.join("sessions")).unwrap();
assert!(set.excluded_by_lists(&exclude, &root.join("sessions"), Asked::Possibly));
let mut by_name = entry(&root);
by_name.include = Some(vec!["config.toml".to_string()]);
let mut named = TrackedSet::default();
named.push(by_name);
let exclude = named.exclude_set().unwrap();
std::fs::create_dir_all(root.join("sessions")).unwrap();
std::fs::write(root.join("sessions/one.jsonl"), "noise").unwrap();
std::fs::write(root.join("config.toml"), "keep").unwrap();
assert!(
named.excluded_by_lists(&exclude, &root.join("sessions/one.jsonl"), Asked::Possibly),
"the watcher woke for a transcript the list does not select"
);
assert!(!named.excluded_by_lists(&exclude, &root.join("config.toml"), Asked::Possibly));
std::fs::remove_file(root.join("config.toml")).unwrap();
assert!(!named.excluded_by_lists(&exclude, &root.join("config.toml"), Asked::Possibly));
assert!(!named.excluded_by_lists(&exclude, &root.join("sessions"), Asked::Possibly));
std::fs::remove_dir_all(root.join("rules")).unwrap();
assert!(
!set.excluded_by_lists(&exclude_anchored, &root.join("rules"), Asked::Possibly),
"a removed directory of selected files was ignored"
);
std::fs::remove_dir_all(root.join("sessions")).unwrap();
assert!(
set.excluded_by_lists(&exclude_anchored, &root.join("sessions"), Asked::Possibly),
"a removed directory nothing could select woke the watcher"
);
assert!(set.would_retain(&root.join("rules/one.md")).unwrap());
assert!(!set.would_retain(&root.join("notes.md")).unwrap());
assert!(!set.excluded_by_lists(&exclude, &root.join("rules/one.md"), Asked::Possibly));
assert!(set.excluded_by_lists(&exclude, &root.join("notes.md"), Asked::Possibly));
}
#[test]
fn a_pattern_that_selects_below_a_directory_reaches_into_it() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("rules/deep")).unwrap();
std::fs::write(root.join("rules/one.md"), "keep").unwrap();
std::fs::write(root.join("rules/deep/two.md"), "keep").unwrap();
for (pattern, selects_deep) in [
("rules", true),
("rules/**", true),
("rules/*", true),
("sessions/**", false),
] {
let mut tracked = entry(&root);
tracked.include = Some(vec![pattern.to_string()]);
let deep = root.join("rules/deep/two.md");
assert_eq!(
tracked.is_included(&deep),
selects_deep,
"{pattern} selecting {}",
deep.display()
);
assert_eq!(
tracked.include_prunes(&root.join("rules/deep")),
!selects_deep,
"{pattern} pruning rules/deep"
);
}
}
#[test]
fn retention_keeps_what_the_list_still_selects() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("rules")).unwrap();
let mut tracked = entry(&root);
tracked.include = Some(vec!["rules/**".to_string()]);
let mut set = TrackedSet::default();
set.push(tracked);
let selected = root.join("rules/one.md");
let dropped = root.join("sessions/one.jsonl");
assert!(
set.would_retain(&selected).unwrap(),
"a selected file was not carried forward"
);
assert!(
!set.would_retain(&dropped).unwrap(),
"a file the list no longer selects was kept because it could not be read"
);
let exclude = set.exclude_set().unwrap();
assert!(!set.excluded_by_lists(&exclude, &selected, Asked::Possibly));
}
#[test]
fn a_file_at_the_entry_path_is_not_read_as_absent() {
use crate::system::history::replay::{PathState, classify_coverage};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("rules")).unwrap();
std::fs::write(root.join("rules/one.md"), "keep").unwrap();
let mut tracked = entry(&root);
tracked.include = Some(vec!["rules/**".to_string()]);
let mut set = TrackedSet::default();
set.push(tracked);
let coverage = set.coverage(&set.walk().unwrap());
for (path, expected_absent) in [
(root.clone(), false),
(root.join("notes.md"), false),
(root.join("rules/gone.md"), true),
] {
let state = classify_coverage(&coverage, &display_path(&path));
assert_eq!(
matches!(state, PathState::Absent),
expected_absent,
"{}",
path.display()
);
}
}
#[cfg(unix)]
#[test]
fn a_pattern_with_a_backslash_selects_what_it_names_on_unix() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
let odd = root.join("we\\ird");
std::fs::create_dir_all(&odd).unwrap();
std::fs::write(odd.join("kept.md"), "keep").unwrap();
let mut tracked = entry(&root);
tracked.include = Some(vec!["we\\ird/**".to_string()]);
let mut set = TrackedSet::default();
set.push(tracked);
assert!(set.entries[0].is_included(&odd.join("kept.md")));
assert!(
!set.entries[0].include_prunes(&odd),
"a directory the list selects was skipped unopened"
);
assert!(set.walk().unwrap().files.contains_key(&odd.join("kept.md")));
}
#[test]
fn a_path_with_no_kind_answers_the_question_it_was_asked() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("sample");
std::fs::create_dir_all(root.join("nested")).unwrap();
let mut tracked = entry(&root);
tracked.include = Some(vec!["keep".into()]);
let mut set = TrackedSet::default();
set.push(tracked);
let exclude = set.exclude_set().unwrap();
let narrowed = root.join("nested/leave");
assert!(
set.excluded_by_lists(&exclude, &narrowed, Asked::Exactly),
"a path the include list does not select counted as managed, so a narrowing elsewhere would delete it here"
);
assert!(
!set.excluded_by_lists(&exclude, &narrowed, Asked::Possibly),
"the watcher stopped waking for a path that has just vanished"
);
let selected = root.join("nested/keep");
assert!(!set.excluded_by_lists(&exclude, &selected, Asked::Exactly));
assert!(!set.excluded_by_lists(&exclude, &selected, Asked::Possibly));
}
fn entry(path: &Path) -> TrackedEntry {
TrackedEntry::new(
path.to_path_buf(),
"track",
Policy::for_mode(FileMode::Track),
)
}
#[test]
fn the_most_specific_entry_owns_a_file() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("root");
let child = root.join("child");
let mut set = TrackedSet::default();
set.push(entry(&root));
set.push(entry(&child));
assert_eq!(
set.entry_for(&child.join("file")).map(|e| &e.path),
Some(&child)
);
assert_eq!(
set.entry_for(&root.join("other")).map(|e| &e.path),
Some(&root)
);
assert!(set.entry_for(&tmp.path().join("elsewhere")).is_none());
for path in [child.join("file"), root.join("other")] {
let index = set
.entry_index_for(&path)
.expect("a covered path has an index");
assert_eq!(
Some(&set.entries[index].path),
set.entry_for(&path).map(|entry| &entry.path)
);
}
assert!(set.entry_index_for(&tmp.path().join("elsewhere")).is_none());
let mut set = TrackedSet::default();
set.push(entry(&root));
set.push(entry(&root));
assert_eq!(set.entries.len(), 1);
let mut encrypted = entry(&root);
encrypted.policy.encrypt = true;
set.push(encrypted);
assert_eq!(set.invalid.len(), 1);
}
#[test]
fn existing_leaf_uses_the_filesystem_canonical_spelling() {
let temp = tempfile::tempdir().unwrap();
let actual = temp.path().join("MixedCase");
std::fs::write(&actual, "contents").unwrap();
let alternative = temp.path().join("mixedcase");
if alternative.exists() {
assert_eq!(normalize_target(&alternative), normalize_target(&actual));
}
}
#[cfg(windows)]
#[test]
fn junction_leaf_keeps_its_enrolled_location() {
let temp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp.path()).unwrap();
let target = root.join("target");
let link = root.join("junction");
std::fs::create_dir(&target).unwrap();
junction::create(&target, &link).unwrap();
assert_eq!(normalize_target(&link), link);
assert_ne!(normalize_target(&link), target);
}
#[test]
fn deployment_requests_do_not_enroll_files_or_sources() {
use crate::system::files::FileRequest;
use crate::system::resources::ResourceOrigin;
let tmp = tempfile::tempdir().unwrap();
let mut set = TrackedSet::default();
for mode in [
FileMode::Copy,
FileMode::Template,
FileMode::Content,
FileMode::Symlink,
FileMode::SymlinkEach,
] {
set.add_requests([FileRequest {
target_raw: tmp.path().join("output").display().to_string(),
target: tmp.path().join("output"),
source: tmp.path().join("source"),
content: None,
mode,
exclude: vec![],
include: None,
manifest: None,
permissions: None,
base: tmp.path().to_path_buf(),
origin: ResourceOrigin {
config: tmp.path().join("config.toml"),
config_root: tmp.path().to_path_buf(),
environment: vec![],
source: None,
},
policy: Policy::for_mode(mode),
variants: vec![],
enabled: true,
remove_empty: false,
dot_prefix: false,
relative: false,
}]);
}
assert!(set.entries.is_empty());
assert_eq!(
set.required_sources,
vec![normalize_target(&tmp.path().join("source"))]
);
assert!(set.walk().unwrap().files.is_empty());
}
#[test]
fn enrolled_directories_include_new_descendants_but_not_credentials() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("templates");
std::fs::create_dir_all(&root).unwrap();
let mut set = TrackedSet::default();
set.push(entry(&root));
assert!(set.walk().unwrap().files.is_empty());
let child = root.join("nested");
std::fs::create_dir_all(&child).unwrap();
let included = child.join("gitconfig.tera");
std::fs::write(&included, "template").unwrap();
std::fs::write(child.join("config.local.toml"), "private").unwrap();
std::fs::write(child.join("credentials.json"), "private").unwrap();
let walk = set.walk().unwrap();
assert_eq!(walk.files.len(), 1);
assert!(walk.files.contains_key(&included));
assert_eq!(walk.omitted.len(), 2);
assert!(set.would_capture(&included).unwrap());
assert!(!set.would_capture(&child.join("config.local.toml")).unwrap());
assert!(!set.would_capture(&child.join("credentials.json")).unwrap());
}
#[test]
fn the_credential_guard_matches_by_name_alone() {
let policy = Policy::for_mode(FileMode::Track);
let dir = Path::new("/nonexistent-mise-test/.ssh");
for name in [
"id_ed25519",
"id_ed25519.pub",
"secrets.fish",
"client_secret.pub",
"oauth_token.pub",
"credentials.pub",
] {
assert_eq!(
capture_exclusion(&dir.join(name), &policy),
Some(CREDENTIAL_REASON),
"{name}"
);
}
assert_eq!(
capture_exclusion(&dir.join("recipients.txt"), &policy),
None
);
assert_eq!(
capture_exclusion(&dir.join("config.local.toml"), &policy),
Some("machine-local configuration")
);
let mut encrypted = policy;
encrypted.encrypt = true;
assert_eq!(capture_exclusion(&dir.join("id_ed25519"), &encrypted), None);
}
#[test]
fn a_pattern_is_anchored_only_when_it_holds_a_separator() {
assert!(is_path_anchored("~/.config/app/**"));
assert!(is_path_anchored("keys/*.pem"));
assert!(!is_path_anchored("*.pem"));
assert!(!is_path_anchored("cache"));
assert_eq!(is_path_anchored("~\\.config\\app"), cfg!(windows));
}
#[test]
fn a_star_in_a_path_pattern_stops_at_a_separator() {
let root = Path::new(ROOT);
let set = ExcludeSet::new(&["keys/*.pem".to_string()]).unwrap();
assert!(set.is_match(&root.join("app/keys/a.pem"), root));
assert!(!set.is_match(&root.join("app/keys/sub/a.pem"), root));
let set = ExcludeSet::new(&["keys/**/*.pem".to_string()]).unwrap();
assert!(set.is_match(&root.join("app/keys/a.pem"), root));
assert!(set.is_match(&root.join("app/keys/sub/a.pem"), root));
}
#[test]
fn a_name_glob_excludes_any_path_component() {
let set = ExcludeSet::new(&["cache".to_string()]).unwrap();
assert!(set.is_match(
Path::new("/nonexistent-mise-test/.codex/cache"),
Path::new(ROOT)
));
assert!(set.is_match(
Path::new("/nonexistent-mise-test/.codex/cache/index"),
Path::new(ROOT)
));
assert!(set.is_match(
Path::new("/nonexistent-mise-test/cache/deep/index"),
Path::new(ROOT)
));
assert!(!set.is_match(
Path::new("/nonexistent-mise-test/.codex/config.toml"),
Path::new(ROOT)
));
assert!(!set.is_match(
Path::new("/nonexistent-mise-test/.codex/cached"),
Path::new(ROOT)
));
let set = ExcludeSet::new(&["*.log".to_string()]).unwrap();
assert!(set.is_match(
Path::new("/nonexistent-mise-test/a/b/run.log"),
Path::new(ROOT)
));
let set = ExcludeSet::new(&["cache".to_string(), "!cache".to_string()]).unwrap();
assert!(!set.is_match(
Path::new("/nonexistent-mise-test/.codex/cache"),
Path::new(ROOT)
));
assert!(!is_builtin_credential(
Path::new("/nonexistent-mise-test/oauth/notes.txt"),
"notes.txt"
));
}
#[cfg(unix)]
#[test]
fn an_excluded_directory_is_not_descended_into() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("sessions")).unwrap();
std::fs::write(root.join("config.toml"), "keep").unwrap();
std::fs::write(root.join("sessions/one.jsonl"), "drop").unwrap();
std::fs::set_permissions(
root.join("sessions"),
std::fs::Permissions::from_mode(0o000),
)
.unwrap();
let restore = || {
std::fs::set_permissions(
root.join("sessions"),
std::fs::Permissions::from_mode(0o700),
)
.unwrap()
};
if std::fs::read_dir(root.join("sessions")).is_ok() {
restore();
return;
}
for pattern in [
"sessions".to_string(),
"sessions/**".to_string(),
format!("{}/sessions/**", root.display()),
] {
let mut set = TrackedSet {
exclude: vec![pattern.clone()],
..Default::default()
};
set.push(entry(&root));
let walk = set.walk().unwrap();
assert_eq!(walk.files.len(), 1, "{pattern}");
assert!(
walk.files.contains_key(&root.join("config.toml")),
"{pattern}"
);
assert!(walk.omitted.is_empty(), "{pattern}: {:?}", walk.omitted);
}
let mut set = TrackedSet {
exclude: vec![
"sessions/**".to_string(),
"!sessions/keep.jsonl".to_string(),
],
..Default::default()
};
set.push(entry(&root));
let walk = set.walk().unwrap();
assert!(
walk.omitted
.iter()
.any(|omitted| omitted.reason.starts_with("unreadable")),
"{:?}",
walk.omitted
);
restore();
}
#[test]
fn a_relative_pattern_matches_at_any_depth_and_ignores_the_working_directory() {
let cwd = std::env::current_dir().unwrap();
let log = ExcludeSet::new(&["**/*.log".to_string()]).unwrap();
assert!(log.is_match(
Path::new("/nonexistent-mise-test/a/b/c.log"),
Path::new(ROOT)
));
assert!(log.is_match(&cwd.join("a/b/c.log"), &cwd));
assert!(!log.is_match(
Path::new("/nonexistent-mise-test/a/b/c.txt"),
Path::new(ROOT)
));
for pattern in ["sessions/**", "./sessions/**"] {
let sessions = ExcludeSet::new(&[pattern.to_string()]).unwrap();
for root in [Path::new("/nonexistent-mise-test/.codex"), cwd.as_path()] {
assert!(
sessions.is_match(&root.join("sessions/one.jsonl"), root),
"{pattern} under {}",
root.display()
);
}
assert!(!sessions.is_match(
Path::new("/somewhere/else/sessions/one.jsonl"),
Path::new("/nonexistent-mise-test/.codex")
));
assert!(!sessions.is_match(
Path::new("/nonexistent-mise-test/.codex/config.toml"),
Path::new("/nonexistent-mise-test/.codex")
));
}
let home_pattern = ExcludeSet::new(&["~/.mise-test-tilde/**".to_string()]).unwrap();
let home = crate::dirs::HOME.to_path_buf();
assert!(home_pattern.is_match(&home.join(".mise-test-tilde/x"), &home));
assert!(!home_pattern.is_match(
Path::new("/nonexistent-mise-test/a/.mise-test-tilde/x"),
Path::new("/nonexistent-mise-test")
));
let above = ExcludeSet::new(&["sessions/**".to_string()]).unwrap();
let nested_root = Path::new("/nonexistent-mise-test/sessions/.codex");
assert!(!above.is_match(&nested_root.join("config.toml"), nested_root));
assert!(above.is_match(&nested_root.join("sessions/one.jsonl"), nested_root));
let keys = ExcludeSet::new(&["./keys/**".to_string()]).unwrap();
assert!(keys.is_match(
Path::new("/nonexistent-mise-test/.config/app/keys/id"),
Path::new(ROOT)
));
}
#[cfg(unix)]
#[test]
fn an_absolute_pattern_follows_a_symlinked_ancestor() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real");
std::fs::create_dir_all(real.join("app")).unwrap();
std::fs::write(real.join("app/store.kdb"), "vault").unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let walked = normalize_target(&link.join("app/store.kdb"));
assert!(walked.starts_with(normalize_target(&real)));
let pattern = link.join("app/**").to_string_lossy().into_owned();
let set = ExcludeSet::new(std::slice::from_ref(&pattern)).unwrap();
assert!(set.is_match(&walked, Path::new(ROOT)));
}
#[cfg(unix)]
#[test]
fn an_absolute_pattern_matches_the_path_as_written_too() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real");
std::fs::create_dir_all(real.join("codex")).unwrap();
std::fs::write(real.join("codex/one.jsonl"), "drop").unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let root = link.join("codex");
let pattern = format!("{}/**", root.display());
let set = ExcludeSet::new(std::slice::from_ref(&pattern)).unwrap();
assert!(set.is_match(&root.join("one.jsonl"), Path::new(ROOT)));
assert!(set.is_match(&normalize_target(&root).join("one.jsonl"), Path::new(ROOT)));
}
#[test]
fn a_negation_below_an_excluded_directory_still_re_includes() {
let root = Path::new(ROOT);
let rules = ["cache".to_string(), "!cache/keep.conf".to_string()];
let set = ExcludeSet::new(&rules).unwrap();
for (file, excluded) in [("cache/index", true), ("cache/keep.conf", false)] {
let path = root.join(file);
assert_eq!(
set.is_match(&path, root),
excluded,
"matcher: {file} under {} should be excluded={excluded}",
root.display()
);
}
assert!(
set.may_reinclude_below(&root.join("cache")),
"a negation naming something inside the directory must keep it walked"
);
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
let cache = root.join("cache");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(root.join("config.toml"), "keep").unwrap();
std::fs::write(cache.join("index"), "drop").unwrap();
std::fs::write(cache.join("keep.conf"), "keep").unwrap();
for negation in [
format!("!{}/cache/keep.conf", root.display()),
"!cache/keep.conf".to_string(),
] {
let mut set = TrackedSet {
exclude: vec!["cache".to_string(), negation.clone()],
..Default::default()
};
set.push(entry(&root));
let walk = set.walk().unwrap();
let mut names: Vec<String> = walk
.files
.keys()
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, ["config.toml", "keep.conf"], "{negation}");
}
let mut set = TrackedSet {
exclude: vec![
"cache".to_string(),
format!("!{}/cache/keep.conf", root.display()),
],
..Default::default()
};
set.push(entry(&root));
let walk = set.walk().unwrap();
for file in ["config.toml", "cache/index", "cache/keep.conf"] {
let path = root.join(file);
assert_eq!(
walk.files.contains_key(&path),
set.would_retain(&path).unwrap(),
"{file}"
);
}
let exclude = ExcludeSet::new(&["cache".to_string()]).unwrap();
assert!(
!exclude.may_reinclude_below(&cache),
"no negation at all, so {} must be prunable",
cache.display()
);
let elsewhere = tmp.path().join("elsewhere/keep.conf");
let exclude =
ExcludeSet::new(&["cache".to_string(), format!("!{}", elsewhere.display())]).unwrap();
assert!(
!exclude.may_reinclude_below(&cache),
"a negation at {} cannot re-include anything below {}",
elsewhere.display(),
cache.display()
);
}
#[test]
fn capture_would_retain_watcher_and_replay_agree_about_every_path() {
use crate::system::history::replay::{PathState, classify_coverage};
let tmp = tempfile::tempdir().unwrap();
let outer = tmp.path().join("a");
let inner = outer.join("b");
std::fs::create_dir_all(inner.join("cache")).unwrap();
std::fs::write(outer.join("outer.toml"), "keep").unwrap();
std::fs::write(inner.join("inner.toml"), "keep").unwrap();
std::fs::write(inner.join("cache/index"), "drop").unwrap();
std::fs::write(inner.join("cache/keep.conf"), "keep").unwrap();
let repository = inner.join("cache/repo");
std::fs::create_dir_all(repository.join(".git")).unwrap();
std::fs::write(repository.join("secret"), "not ours").unwrap();
let mut set = TrackedSet {
exclude: vec![
"b".to_string(),
"cache".to_string(),
format!("!{}/cache/keep.conf", inner.display()),
format!("!{}/cache/repo/secret", inner.display()),
],
..Default::default()
};
set.push(entry(&outer));
set.push(entry(&inner));
let walk = set.walk().unwrap();
let coverage = set.coverage(&walk);
for (file, expected) in [
(outer.join("outer.toml"), true),
(inner.join("inner.toml"), true),
(inner.join("cache/index"), false),
(inner.join("cache/keep.conf"), true),
] {
let display = display_path(&file);
assert_eq!(
walk.files.contains_key(&file),
expected,
"capture {display}"
);
assert_eq!(
set.would_retain(&file).unwrap(),
expected,
"would_retain {display}"
);
assert_eq!(
!set.excluded_by_lists(&set.exclude_set().unwrap(), &file, Asked::Possibly),
expected,
"watcher {display}"
);
let covered = !matches!(classify_coverage(&coverage, &display), PathState::Uncovered);
assert_eq!(covered, expected, "replay {display}");
}
assert_eq!(
walk.nested
.iter()
.map(|nested| nested.path.as_str())
.collect::<Vec<_>>(),
vec![display_path(&repository).as_str()],
);
let describe = |state: &PathState| match state {
PathState::Absent => "absent".to_string(),
PathState::Uncovered => "uncovered".to_string(),
PathState::Omitted(reason) => format!("omitted: {reason}"),
PathState::Unevaluable(reason) => format!("unevaluable: {reason}"),
};
let secret = repository.join("secret");
assert!(!walk.files.contains_key(&secret), "capture");
assert!(!set.would_retain(&secret).unwrap(), "would_retain");
assert!(
set.excluded_by_lists(&set.exclude_set().unwrap(), &secret, Asked::Possibly),
"watcher"
);
assert!(
matches!(
classify_coverage(&coverage, &display_path(&secret)),
PathState::Omitted(reason) if reason.contains("repository")
),
"replay: {}",
describe(&classify_coverage(&coverage, &display_path(&secret)))
);
let display = display_path(outer.join("outer.toml"));
for (name, broken, omitted_as) in [
(
"written before this matcher",
{
let mut c = coverage.clone();
c.matcher = None;
c
},
None,
),
(
"written by a newer matcher",
{
let mut c = coverage.clone();
c.matcher = Some(super::MATCHER_VERSION + 1);
c
},
None,
),
(
"a repository recorded as skipped",
{
let mut c = coverage.clone();
c.nested.push(crate::system::history::store::PathReason {
path: display_path(&outer),
reason: NESTED_REPOSITORY_REASON.into(),
});
c
},
Some(NESTED_REPOSITORY_REASON),
),
#[cfg(windows)]
(
"a repository recorded with the host's separators",
{
let mut c = coverage.clone();
c.nested.push(crate::system::history::store::PathReason {
path: display_path(&outer).replace('/', "\\"),
reason: NESTED_REPOSITORY_REASON.into(),
});
c
},
Some(NESTED_REPOSITORY_REASON),
),
] {
let state = classify_coverage(&broken, &display);
let got = describe(&state);
match omitted_as {
Some(reason) => assert!(
matches!(&state, PathState::Omitted(found) if found == reason),
"{name}: expected the record's own explanation, got {got}"
),
None => assert!(
matches!(&state, PathState::Unevaluable(_)),
"{name}: expected an uninterpretable record, got {got}"
),
}
assert!(
state.skip_reason("0123456789").is_some(),
"{name}: {got} must never delete a live file"
);
}
let outside = display_path(tmp.path().join("outside.toml"));
let state = classify_coverage(&coverage, &outside);
assert!(
matches!(state, PathState::Uncovered),
"a path under no entry is uncovered, got {}",
describe(&state)
);
assert!(
state.skip_reason("0123456789").is_some(),
"an uncovered path must never delete a live file"
);
let mut unusable = coverage.clone();
unusable
.exclude
.push("$MISE_TEST_UNSUPPORTED/**".to_string());
let state = classify_coverage(&unusable, &display);
assert!(matches!(state, PathState::Unevaluable(_)));
assert!(state.skip_reason("0123456789").is_some());
let mut plain = coverage.clone();
plain.exclude.clear();
assert!(!matches!(
classify_coverage(&plain, &display),
PathState::Unevaluable(_)
));
}
#[test]
fn an_include_list_selects_what_a_tracked_directory_saves() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("rules/deep")).unwrap();
std::fs::create_dir_all(root.join("sessions")).unwrap();
std::fs::write(root.join("config.toml"), "keep").unwrap();
std::fs::write(root.join("notes.md"), "noise").unwrap();
std::fs::write(root.join("rules/one.md"), "keep").unwrap();
std::fs::write(root.join("rules/deep/two.md"), "keep").unwrap();
std::fs::write(root.join("sessions/one.jsonl"), "noise").unwrap();
let captured = |include: Option<&[&str]>, exclude: &[&str]| -> Vec<String> {
let mut entry = entry(&root);
entry.include = include.map(|p| p.iter().map(|p| (*p).to_string()).collect());
entry.exclude = Some(exclude.iter().map(|p| (*p).to_string()).collect());
let mut set = TrackedSet::default();
set.push(entry);
let walk = set.walk().unwrap();
let mut names: Vec<String> = walk
.files
.keys()
.map(|path| {
path.strip_prefix(&root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
names.sort();
for path in walk.files.keys() {
assert!(set.would_retain(path).unwrap(), "{}", path.display());
}
names
};
assert_eq!(
captured(None, &[]),
[
"config.toml",
"notes.md",
"rules/deep/two.md",
"rules/one.md",
"sessions/one.jsonl"
]
);
assert_eq!(
captured(Some(&["config.toml", "rules/**"]), &[]),
["config.toml", "rules/deep/two.md", "rules/one.md"]
);
std::fs::write(root.join("telemetry.json"), "noise").unwrap();
assert_eq!(
captured(Some(&["config.toml", "rules/**"]), &[]),
["config.toml", "rules/deep/two.md", "rules/one.md"]
);
assert!(captured(Some(&[]), &[]).is_empty());
assert!(captured(Some(&["nothing-here"]), &[]).is_empty());
assert_eq!(
captured(Some(&["config.toml", "rules/**"]), &["rules/deep"]),
["config.toml", "rules/one.md"]
);
}
#[test]
fn an_include_list_decides_what_is_captured_and_says_when_it_is_plaintext() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("fish");
std::fs::create_dir_all(root.join("functions")).unwrap();
std::fs::write(root.join("functions/hello.fish"), "function hello; end").unwrap();
std::fs::write(root.join("functions/secrets.fish"), "set -x TOKEN x").unwrap();
let walk_with = |include: Option<&[&str]>, encrypt: bool| {
let mut entry = entry(&root);
entry.include = include.map(|p| p.iter().map(|p| (*p).to_string()).collect());
entry.policy.encrypt = encrypt;
let mut set = TrackedSet::default();
set.push(entry);
set.walk().unwrap()
};
let holds = |walk: &Walk, name: &str| walk.files.keys().any(|p| p.ends_with(name));
let walk = walk_with(None, false);
assert!(holds(&walk, "hello.fish"));
assert!(!holds(&walk, "secrets.fish"));
assert!(walk.plaintext.is_empty());
assert!(
walk.omitted
.iter()
.any(|o| o.path.ends_with("secrets.fish"))
);
for include in [
&["functions/secrets.fish"][..],
&["functions/*.fish"][..],
&["**"][..],
] {
let walk = walk_with(Some(include), false);
assert!(holds(&walk, "secrets.fish"), "{include:?}");
assert!(walk.omitted.is_empty(), "{include:?}");
assert_eq!(walk.plaintext.len(), 1, "{include:?}");
assert_eq!(walk.capture_warnings.len(), 1, "{include:?}");
assert!(
walk.warnings.is_empty(),
"capture notices must wait for a commit"
);
assert!(
walk.plaintext[0].path.ends_with("secrets.fish"),
"{include:?}"
);
let mut set = TrackedSet::default();
let mut owner = entry(&root);
owner.include = Some(include.iter().map(|p| (*p).to_string()).collect());
set.push(owner);
assert!(
set.would_retain(&root.join("functions/secrets.fish"))
.unwrap(),
"{include:?}"
);
}
let walk = walk_with(Some(&["functions/secrets.fish"]), true);
assert!(holds(&walk, "secrets.fish"));
assert!(walk.plaintext.is_empty());
}
#[test]
fn an_empty_include_selects_nothing_even_for_a_single_file_entry() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("app");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("credentials"), "token").unwrap();
let file = dir.join("credentials");
let walk_with = |include: Option<&[&str]>| {
let mut e = entry(&file);
e.include = include.map(|p| p.iter().map(|p| (*p).to_string()).collect());
let mut set = TrackedSet::default();
set.push(e);
(set.walk().unwrap(), set)
};
let (walk, set) = walk_with(None);
assert!(walk.files.is_empty());
assert!(!set.would_retain(&file).unwrap());
let (walk, set) = walk_with(Some(&[]));
assert!(walk.files.is_empty());
assert!(walk.plaintext.is_empty());
assert!(!set.would_retain(&file).unwrap());
let (walk, set) = walk_with(Some(&["credentials"]));
assert!(walk.files.is_empty(), "{:?}", walk.files);
assert!(walk.plaintext.is_empty());
assert!(!set.would_retain(&file).unwrap());
let mut owner = entry(&dir);
owner.include = Some(vec!["credentials".to_string()]);
let mut set = TrackedSet::default();
set.push(owner);
let walk = set.walk().unwrap();
assert!(walk.files.contains_key(&file));
assert_eq!(walk.plaintext.len(), 1);
assert!(set.would_retain(&file).unwrap());
}
#[test]
fn an_include_list_cannot_reach_into_a_nested_repository() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("hammerspoon");
let plugin = root.join("Spoons/Sky.spoon");
std::fs::create_dir_all(plugin.join(".git")).unwrap();
std::fs::write(plugin.join("init.lua"), "return {}").unwrap();
std::fs::write(root.join("init.lua"), "top").unwrap();
let walk_with = |include: &[&str]| {
let mut entry = entry(&root);
entry.include = Some(include.iter().map(|p| (*p).to_string()).collect());
let mut set = TrackedSet::default();
set.push(entry);
set.walk().unwrap()
};
let walk = walk_with(&["Spoons/Sky.spoon/**"]);
assert!(walk.files.is_empty(), "{:?}", walk.files);
assert_eq!(walk.nested.len(), 1);
assert!(walk.nested[0].reason.contains("selects nothing inside it"));
assert!(walk.nested[0].reason.contains("Spoons/Sky.spoon/**"));
let walk = walk_with(&["init.lua"]);
assert!(walk.files.contains_key(&root.join("init.lua")));
assert!(!walk.files.contains_key(&plugin.join("init.lua")));
assert_eq!(walk.nested.len(), 1);
assert!(walk.nested[0].reason.contains("\"init.lua\""));
let walk = walk_with(&["other/**"]);
assert!(walk.nested.is_empty(), "{:?}", walk.nested);
assert!(walk.files.is_empty(), "{:?}", walk.files);
let mut plain = entry(&root);
plain.include = None;
let mut set = TrackedSet::default();
set.push(plain);
let walk = set.walk().unwrap();
assert_eq!(walk.nested.len(), 1);
assert_eq!(walk.nested[0].reason, NESTED_REPOSITORY_REASON);
}
#[test]
fn display_under_accepts_either_separator() {
assert!(display_under("~/.ssh", "~/.ssh"));
assert!(display_under("~/.ssh/id_test", "~/.ssh"));
assert!(!display_under("~/.sshd/x", "~/.ssh"));
assert!(!display_under("~/.ssh", "~/.ssh/id_test"));
#[cfg(windows)]
{
assert!(display_under("~\\.ssh\\id_test", "~\\.ssh"));
assert!(display_under("~\\.ssh\\id_test", "~/.ssh"));
assert!(display_under("~/.ssh/id_test", "~\\.ssh"));
assert!(display_under("~\\.ssh", "~/.ssh"));
assert!(!display_under("~\\.sshd\\x", "~/.ssh"));
}
#[cfg(unix)]
{
assert!(!display_under("~/.ssh\\id_test", "~/.ssh"));
assert!(display_under("~/.ssh\\id_test", "~/.ssh\\id_test"));
assert!(!display_under("~\\.ssh\\id_test", "~/.ssh"));
}
}
#[test]
fn display_under_matches_home_and_native_paths() {
let root = crate::dirs::HOME.join(".nested");
let child = root.join("plugin");
assert!(display_under("~/.nested/plugin", &root.to_string_lossy()));
assert!(display_under(&child.to_string_lossy(), "~/.nested"));
assert!(!display_under(
"~/.nested-other/plugin",
&root.to_string_lossy()
));
}
#[test]
fn omission_reports_list_few_and_summarize_many() {
let omitted = |n: usize| -> Vec<PathReason> {
(0..n)
.map(|i| PathReason {
path: format!("~/.config/app/secret{i}"),
reason: CREDENTIAL_REASON.into(),
})
.collect()
};
assert!(omission_report(&[], &[]).is_empty());
let few = omission_report(&omitted(2), &[]);
assert_eq!(few.len(), 2);
assert_eq!(
few[0],
format!("omitted: ~/.config/app/secret0 ({CREDENTIAL_REASON})")
);
let many = omission_report(&omitted(OMISSION_LINES + 1), &[]);
assert_eq!(
many,
vec![format!(
"{} files omitted from capture (credential store); `mise dot paths` lists them",
OMISSION_LINES + 1
)]
);
let mut mixed = omitted(1);
mixed.push(PathReason {
path: "~/.config/app/config.local.toml".into(),
reason: "machine-local configuration".into(),
});
assert_eq!(
omission_summary(&mixed, &[]),
"2 files omitted from capture (1 credential store); `mise dot paths` lists them"
);
let nested = vec![PathReason {
path: "~/.hammerspoon/Spoons/Sky.spoon".into(),
reason: NESTED_REPOSITORY_REASON.into(),
}];
assert_eq!(
omission_report(&[], &nested),
vec![format!(
"nested: ~/.hammerspoon/Spoons/Sky.spoon ({NESTED_REPOSITORY_REASON})"
)]
);
assert_eq!(
omission_summary(&omitted(1), &nested),
"1 files omitted from capture (credential store); 1 nested repository skipped; `mise dot paths` lists them"
);
}
#[test]
fn a_nested_repository_is_skipped_and_tracking_it_captures_its_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("plugins");
let plugin = root.join("nested");
std::fs::create_dir_all(plugin.join(".git")).unwrap();
std::fs::write(plugin.join(".git/HEAD"), "ref: refs/heads/main").unwrap();
std::fs::write(plugin.join("init.lua"), "return {}").unwrap();
std::fs::write(root.join("init.lua"), "top").unwrap();
let mut set = TrackedSet::default();
set.push(entry(&root));
let walk = set.walk().unwrap();
assert!(walk.files.contains_key(&root.join("init.lua")));
assert!(!walk.files.contains_key(&plugin));
assert!(!walk.files.contains_key(&plugin.join("init.lua")));
assert_eq!(walk.nested.len(), 1);
assert_eq!(walk.nested[0].path, display_path(&plugin));
assert_eq!(walk.nested[0].reason, NESTED_REPOSITORY_REASON);
assert!(walk.nested[0].reason.contains("track it directly"));
assert!(walk.omitted.is_empty());
assert_eq!(set.coverage(&walk).nested, walk.nested);
assert!(!set.would_capture(&plugin).unwrap());
assert!(!set.would_capture(&plugin.join("init.lua")).unwrap());
set.push(entry(&plugin));
let walk = set.walk().unwrap();
assert!(walk.files.contains_key(&plugin.join("init.lua")));
assert!(!walk.files.contains_key(&plugin));
assert!(!walk.files.contains_key(&plugin.join(".git/HEAD")));
assert!(
!walk
.files
.keys()
.any(|path| path.components().any(|c| c.as_os_str() == ".git")),
"`.git` is never captured"
);
assert!(walk.nested.is_empty());
assert!(set.would_capture(&plugin.join("init.lua")).unwrap());
assert!(!set.would_capture(&plugin.join(".git/HEAD")).unwrap());
}
#[test]
fn nested_targets_partition_a_preview() {
let tmp = tempfile::tempdir().unwrap();
let outer = tmp.path().join("codex");
let inner = outer.join("sessions");
std::fs::create_dir_all(&inner).unwrap();
std::fs::write(outer.join("config.toml"), "outer").unwrap();
std::fs::write(inner.join("one.jsonl"), "inner-1").unwrap();
std::fs::write(inner.join("two.jsonl"), "inner-2").unwrap();
std::fs::write(inner.join("auth-token"), "x").unwrap();
let mut set = TrackedSet::default();
set.push(entry(&outer));
set.push(entry(&inner));
let walk = set.walk().unwrap();
let outer_preview = walk.preview_of(&set, 0);
let inner_preview = walk.preview_of(&set, 1);
assert_eq!(outer_preview.files, 1);
assert_eq!(outer_preview.bytes, 5);
assert!(outer_preview.omitted.is_empty());
assert_eq!(inner_preview.files, 2);
assert_eq!(inner_preview.bytes, 14);
assert_eq!(inner_preview.omitted.len(), 1);
assert_eq!(outer_preview.summary(), "1 file, 5 B");
}
#[test]
fn walk_summaries_count_files_and_bytes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("tree");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("a"), "12345").unwrap();
std::fs::write(root.join("sub/b"), "123").unwrap();
std::fs::write(root.join("sub/secret.key"), "x").unwrap();
let set = preview_set(&root, Policy::for_mode(FileMode::Track)).unwrap();
assert_eq!(set.entries.len(), 1);
assert_eq!(set.entries[0].path, normalize_target(&root));
#[cfg(unix)]
std::os::unix::fs::symlink("a", root.join("link")).unwrap();
let walk = set.walk().unwrap();
assert_eq!(walk.file_count(), if cfg!(unix) { 3 } else { 2 });
assert_eq!(walk.bytes(), 8);
assert_eq!(
walk.summary(),
if cfg!(unix) {
"3 files, 8 B"
} else {
"2 files, 8 B"
}
);
assert_eq!(walk.omitted.len(), 1);
assert!(!walk.preview_of(&set, 0).is_large());
assert_eq!(count_and_size(1, 0), "1 file, 0 B");
assert_eq!(with_separators(0), "0");
assert_eq!(with_separators(999), "999");
assert_eq!(with_separators(1000), "1,000");
assert_eq!(with_separators(22972), "22,972");
assert_eq!(with_separators(1234567), "1,234,567");
}
#[test]
fn an_entry_excludes_relative_to_its_own_path() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("codex");
std::fs::create_dir_all(root.join("sessions/deep")).unwrap();
std::fs::create_dir_all(root.join("config/sessions")).unwrap();
std::fs::create_dir_all(root.join("cache")).unwrap();
std::fs::write(root.join("config.toml"), "keep").unwrap();
std::fs::write(root.join("notes.md"), "drop").unwrap();
std::fs::write(root.join("sessions/one.jsonl"), "drop").unwrap();
std::fs::write(root.join("sessions/deep/two.jsonl"), "drop").unwrap();
std::fs::write(root.join("config/sessions/keep.toml"), "keep").unwrap();
std::fs::write(root.join("cache/index"), "drop").unwrap();
let mut entry = entry(&root);
entry.exclude = Some(vec!["*.md".into(), "sessions/**".into(), "cache".into()]);
let mut set = TrackedSet::default();
set.push(entry);
let walk = set.walk().unwrap();
assert_eq!(walk.files.len(), 2);
assert!(walk.files.contains_key(&root.join("config.toml")));
assert!(
walk.files
.contains_key(&root.join("config/sessions/keep.toml"))
);
assert!(walk.omitted.is_empty());
assert!(set.would_retain(&root.join("config.toml")).unwrap());
assert!(!set.would_retain(&root.join("notes.md")).unwrap());
assert!(
!set.would_retain(&root.join("sessions/deep/two.jsonl"))
.unwrap()
);
assert!(!set.would_retain(&root.join("cache/index")).unwrap());
assert!(
set.would_retain(&root.join("config/sessions/keep.toml"))
.unwrap()
);
let mut entry =
super::TrackedEntry::new(root.clone(), "track", Policy::for_mode(FileMode::Track));
entry.exclude = Some(vec!["codex".into()]);
assert!(!entry.is_excluded(&root));
assert!(!entry.is_excluded(tmp.path()));
let mut entry =
super::TrackedEntry::new(root.clone(), "track", Policy::for_mode(FileMode::Track));
entry.exclude = Some(vec!["cache".into()]);
let mut set = TrackedSet {
exclude: vec![
format!("{}/**", root.display()),
format!("!{}/cache/**", root.display()),
],
..Default::default()
};
set.push(entry);
let walk = set.walk().unwrap();
assert!(walk.files.is_empty());
assert!(!set.would_retain(&root.join("cache/index")).unwrap());
assert_eq!(
set.coverage(&walk).entries[0].exclude,
Some(vec!["cache".to_string()])
);
}
#[test]
fn entry_excludes_travel_through_the_enrollment_manifest() {
use crate::system::files::FileRequest;
use crate::system::resources::ResourceOrigin;
let home = normalize(&dirs::HOME);
let target = home.join(".mise-test-entry-exclude");
let mut set = TrackedSet::default();
set.add_requests([FileRequest {
target_raw: "~/.mise-test-entry-exclude".into(),
target: target.clone(),
source: PathBuf::new(),
content: None,
mode: FileMode::Track,
exclude: vec![glob::Pattern::new("sessions").unwrap()],
include: None,
manifest: None,
permissions: None,
base: home.clone(),
origin: ResourceOrigin {
config: home.join(".config/mise/config.toml"),
config_root: home.join(".config/mise"),
environment: vec![],
source: None,
},
policy: Policy {
explicit: crate::system::files::ExplicitFields {
exclude: true,
..Default::default()
},
..Policy::for_mode(FileMode::Track)
},
variants: vec![],
enabled: true,
remove_empty: false,
dot_prefix: false,
relative: false,
}]);
assert_eq!(set.manifest.enrollment.len(), 1);
assert_eq!(
set.manifest.enrollment[0].exclude,
Some(vec!["sessions".to_string()])
);
let rebuilt = set.manifest.tracking().unwrap();
assert_eq!(
rebuilt.entries[0].exclude,
Some(vec!["sessions".to_string()])
);
assert!(rebuilt.entries[0].is_excluded(&target.join("sessions/one")));
}
#[test]
fn tree_paths_round_trip() {
assert_eq!(tree_path_to_display("home/.zshrc"), "~/.zshrc");
assert_eq!(
tree_path_to_display("fs/nonexistent-mise-test/hosts"),
"/nonexistent-mise-test/hosts"
);
assert_eq!(
display_to_tree_path("/nonexistent-mise-test/hosts"),
"fs/nonexistent-mise-test/hosts"
);
}
#[cfg(unix)]
#[test]
fn tracking_a_symlink_does_not_enroll_its_target() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("target");
std::fs::write(&target, "not enrolled").unwrap();
let link = tmp.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let mut set = TrackedSet::default();
set.push(entry(&link));
let walk = set.walk().unwrap();
assert!(walk.files.contains_key(&link));
assert!(!walk.files.contains_key(&target));
}
}