Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ Equivalent declarations are deduplicated. Different declarations for the same
dotfile target, edit `(path, id)`, managed file, managed directory, service, or
Compose project are errors that identify both declaring configs. Independent
roots never acquire precedence from their order in `config_roots`.
Same-target `symlink-each` declarations are the exception: their source trees
compose when their leaf paths are disjoint, while overlapping leaves or
file/directory collisions are reported with both declaring configs.

Other configuration such as tools, tasks, packages, hooks, and repos is not
collected from these roots. Use their existing explicit workflows when those
Expand Down
39 changes: 39 additions & 0 deletions e2e/cli/test_bootstrap_config_roots
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,42 @@ cat <<EOF >"$root_b/mise.toml"
line = "from b"
EOF
assert_fail "mise bootstrap dotfiles status" "conflicting dotfile edit declarations"

# Same-target symlink-each declarations compose by leaf path. Their ownership
# state remains independent even when only one contributor needs an apply.
shared_tree="$PWD/shared-tree"
mkdir -p "$root_a/tree/conf.d" "$root_b/tree/conf.d"
echo a >"$root_a/tree/conf.d/a.toml"
echo b >"$root_b/tree/conf.d/b.toml"
cat <<EOF >"$root_a/mise.toml"
[dotfiles]
"$shared_tree" = { source = "tree", mode = "symlink-each" }
EOF
cat <<EOF >"$root_b/mise.toml"
[dotfiles]
"$shared_tree" = { source = "tree", mode = "symlink-each" }
EOF
assert_succeed "mise bootstrap dotfiles status --json | jq -e '[.files[] | select(.target == \"$shared_tree\")] | length == 2'"
assert_succeed "mise bootstrap dotfiles apply --yes"
assert "readlink $shared_tree/conf.d/a.toml" "$root_a/tree/conf.d/a.toml"
assert "readlink $shared_tree/conf.d/b.toml" "$root_b/tree/conf.d/b.toml"
assert_succeed "mise bootstrap dotfiles status --missing"

rm -rf "$MISE_STATE_DIR/dotfiles"
rm "$shared_tree/conf.d/b.toml"
assert_succeed "mise bootstrap dotfiles apply --yes"
assert "find '$MISE_STATE_DIR/dotfiles' -type f | wc -l | tr -d ' '" "2"

# Unapply uses recorded ownership and does not need to traverse composed
# source trees that have become unreadable.
chmod 000 "$root_a/tree/conf.d"
assert_succeed "mise bootstrap dotfiles unapply --yes"
chmod 755 "$root_a/tree/conf.d"
assert_fail "test -e $shared_tree"
assert_succeed "mise bootstrap dotfiles apply --yes"

# The leaf identity, rather than only the shared directory target, conflicts.
echo conflict >"$root_b/tree/conf.d/a.toml"
assert_fail "mise bootstrap dotfiles status" "conflicting symlink-each declarations for $shared_tree/conf.d/a.toml"
assert_fail "mise bootstrap dotfiles status" "$root_a/mise.toml"
assert_fail "mise bootstrap dotfiles status" "$root_b/mise.toml"
4 changes: 3 additions & 1 deletion src/cli/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2912,7 +2912,9 @@ impl BootstrapStatus {
report: &mut BootstrapStatusReport,
) -> Result<()> {
let mut json_files = vec![];
for req in system::files::files_from_config(config)? {
let files = system::files::files_from_config(config)?;
system::files::validate_composed_symlink_each(&files)?;
for req in files {
let state = match system::files::check(config, &req) {
Ok(state) => state,
Err(err) => system::files::FileState::Differs(format!("{err}")),
Expand Down
1 change: 1 addition & 0 deletions src/cli/dotfiles/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ impl DotfilesStatus {
let mut any_missing = false;

let all_files = system::files::files_from_config(&config)?;
system::files::validate_composed_symlink_each(&all_files)?;
let files = all_files
.iter()
.filter(|req| {
Expand Down
145 changes: 137 additions & 8 deletions src/system/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,24 +153,88 @@ pub enum FileState {
/// Keys union global -> local; a more local config overrides an entry for the
/// same target. Malformed entries and unknown modes warn and are skipped.
pub fn files_from_config(config: &Config) -> Result<Vec<FileRequest>> {
let mut composed: IndexMap<PathBuf, FileRequest> = IndexMap::new();
let mut composed: IndexMap<PathBuf, Vec<FileRequest>> = IndexMap::new();
for config_files in config.bootstrap_config_maps() {
for request in files_from_config_files(config_files) {
if let Some(existing) = composed.get(&request.target) {
if file_requests_match(config, existing, &request) {
continue;
}
let siblings = composed.entry(request.target.clone()).or_default();
if siblings
.iter()
.any(|existing| file_requests_match(config, existing, &request))
{
continue;
}
if let Some(existing) = siblings.iter().find(|existing| {
existing.mode != FileMode::SymlinkEach || request.mode != FileMode::SymlinkEach
}) {
bail!(
"conflicting dotfile declarations for {}\n\n first:\n {}\n\n second:\n {}",
request.target.display(),
existing.origin.conflict_description(),
request.origin.conflict_description(),
);
}
composed.insert(request.target.clone(), request);
siblings.push(request);
}
}
let composed = composed.into_values().flatten().collect::<Vec<_>>();
Ok(composed)
}

/// Same-target `symlink-each` declarations compose by their expanded target
/// paths. Shared directories are fine, but two sources cannot own the same
/// leaf or require a directory where another source places a leaf.
pub(crate) fn validate_composed_symlink_each(requests: &[FileRequest]) -> Result<()> {
let mut groups: IndexMap<&Path, Vec<&FileRequest>> = IndexMap::new();
for request in requests
.iter()
.filter(|request| request.mode == FileMode::SymlinkEach)
{
groups.entry(&request.target).or_default().push(request);
}

for siblings in groups.into_values().filter(|siblings| siblings.len() > 1) {
let mut leaves: IndexMap<PathBuf, &FileRequest> = IndexMap::new();
let mut directories: IndexMap<PathBuf, &FileRequest> = IndexMap::new();
for request in siblings {
// Preserve the normal source-missing/type diagnostic. Once every
// source directory exists its complete composed footprint can be
// checked before status or apply performs any mutation.
if !request.source.is_dir() {
continue;
}
for (_, target) in walk_source_files(request)? {
Comment thread
jdx marked this conversation as resolved.
if let Some(existing) = leaves.get(&target).or_else(|| directories.get(&target)) {
return Err(composed_symlink_each_conflict(&target, existing, request));
}
leaves.insert(target, request);
}
for directory in needed_dirs(request)?
.into_iter()
.filter(|directory| *directory != request.target)
{
if let Some(existing) = leaves.get(&directory) {
return Err(composed_symlink_each_conflict(
&directory, existing, request,
));
}
directories.entry(directory).or_insert(request);
}
}
}
Ok(composed.into_values().collect())
Ok(())
}

fn composed_symlink_each_conflict(
path: &Path,
first: &FileRequest,
second: &FileRequest,
) -> eyre::Report {
eyre::eyre!(
"conflicting symlink-each declarations for {}\n\n first:\n {}\n\n second:\n {}",
path.display(),
first.origin.conflict_description(),
second.origin.conflict_description(),
)
}

/// Returns whether sibling declarations produce the same whole-file resource.
Expand Down Expand Up @@ -1171,7 +1235,7 @@ pub fn execute_apply(plan: ApplyPlan<'_>, opts: &ApplyOpts) -> Result<bool> {
info!("files: {}", describe_applied(req)?);
}
for req in plan.record_symlink_each {
if !plan.todo.iter().any(|(todo, _)| todo.target == req.target) {
if !plan.todo.iter().any(|(todo, _)| std::ptr::eq(*todo, req)) {
save_symlink_each_state(req);
}
}
Expand All @@ -1193,6 +1257,7 @@ pub fn plan_apply<'a>(
requests: &'a [FileRequest],
opts: &ApplyOpts,
) -> Result<ApplyPlan<'a>> {
validate_composed_symlink_each(requests)?;
Comment thread
jdx marked this conversation as resolved.
// pre-rendered template output rides along so it's written as compared,
// and exec() in templates runs once per apply
let mut todo: Vec<(&FileRequest, Option<String>)> = vec![];
Expand Down Expand Up @@ -2212,6 +2277,70 @@ mod tests {
link_req(source, target, FileMode::Symlink)
}

#[test]
fn composed_symlink_each_allows_disjoint_leaves() -> Result<()> {
let dir = tempfile::tempdir()?;
let source_a = dir.path().join("a");
let source_b = dir.path().join("b");
let target = dir.path().join("target");
file::create_dir_all(source_a.join("conf.d"))?;
file::create_dir_all(source_b.join("conf.d"))?;
file::write(source_a.join("conf.d/a.toml"), "a")?;
file::write(source_b.join("conf.d/b.toml"), "b")?;

validate_composed_symlink_each(&[
link_req(&source_a, &target, FileMode::SymlinkEach),
link_req(&source_b, &target, FileMode::SymlinkEach),
])?;
Ok(())
}

#[test]
fn composed_symlink_each_rejects_duplicate_leaves() -> Result<()> {
let dir = tempfile::tempdir()?;
let source_a = dir.path().join("a");
let source_b = dir.path().join("b");
let target = dir.path().join("target");
file::create_dir_all(&source_a)?;
file::create_dir_all(&source_b)?;
file::write(source_a.join("shared"), "a")?;
file::write(source_b.join("shared"), "b")?;

let err = validate_composed_symlink_each(&[
link_req(&source_a, &target, FileMode::SymlinkEach),
link_req(&source_b, &target, FileMode::SymlinkEach),
])
.unwrap_err();
assert!(
err.to_string()
.contains(&target.join("shared").to_string_lossy().to_string())
);
Ok(())
}

#[test]
fn composed_symlink_each_rejects_file_directory_collisions() -> Result<()> {
let dir = tempfile::tempdir()?;
let source_a = dir.path().join("a");
let source_b = dir.path().join("b");
let target = dir.path().join("target");
file::create_dir_all(&source_a)?;
file::create_dir_all(source_b.join("shared"))?;
file::write(source_a.join("shared"), "a")?;
file::write(source_b.join("shared/nested"), "b")?;

let err = validate_composed_symlink_each(&[
link_req(&source_a, &target, FileMode::SymlinkEach),
link_req(&source_b, &target, FileMode::SymlinkEach),
])
.unwrap_err();
assert!(
err.to_string()
.contains(&target.join("shared").to_string_lossy().to_string())
);
Ok(())
}

/// The fix: a file the user wrote is not mise's to replace. This used to pass on Windows,
/// where the entry applied and overwrote it with no `--force` and no message.
#[test]
Expand Down
Loading