Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ one directive per line, `#` are comments
# keeping variables inherited from parent .cade layers
pure

# stop the cascade here; no parent .cade layers load above this dir
disinherit

# load from flake (default shell or named installable)
load
load flake
Expand Down
9 changes: 9 additions & 0 deletions src/cli/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ impl FromStr for Keyword {

let res = match keyword.as_str() {
"pure" => Pure,
"disinherit" => Disinherit,
"call" => {
// split respecting shell quoting
let target = shlex::split(rest_raw).ok_or(ParseError::InvalidQuoting)?;
Expand Down Expand Up @@ -181,6 +182,14 @@ mod tests {
));
}

#[test]
fn bare_disinherit_parses() {
assert!(matches!(
"disinherit".parse::<Keyword>(),
Ok(Keyword::Disinherit)
));
}

#[test]
fn keyword_with_equals_in_args_stays_a_keyword() {
// the `=` belongs to the hook command, not a bare assignment
Expand Down
2 changes: 1 addition & 1 deletion src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1686,7 +1686,7 @@ fn load_single_layer(
Concat(vars) => Ok(CadeAction::Concat(vars.clone())),
Set(env) => Ok(CadeAction::Environ(env.clone())),
// affects only chain construction, not the loaded environment
Watch(_) => continue,
Watch(_) | Disinherit => continue,
}?;
layer.push_action(act);
}
Expand Down
72 changes: 71 additions & 1 deletion src/core/participants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! activation at a given cwd, and which one is the root. No db, no env, no
//! shell output - just the filesystem layout of `.cade` and `.envrc` markers.

use crate::types::Keyword;
use std::path::{Path, PathBuf};

/// what a dir contributes; a co-located `.envrc` yields to `.cade`, so at most one kind
Expand All @@ -21,9 +22,22 @@ fn dir_kind(dir: &Path) -> Option<DirKind> {
}
}

/// true when `dir`'s `.cade` carries a `disinherit` directive (caps the cascade)
fn reads_disinherit(dir: &Path) -> bool {
matches!(
super::read_cade(&dir.join(".cade")),
Ok(kws) if kws.iter().any(|kw| matches!(kw, Keyword::Disinherit))
)
}

/// the active layer set, tip-first: every `.cade` ancestor (the cascade stacks
/// across gaps; an empty intermediate dir does not sever it) unioned with
/// direnv's single nearest `.envrc`. only the permission layer caps it
/// direnv's single nearest `.envrc`. `disinherit` halts the cascade; otherwise
/// only the permission layer caps it
//
// note: a `disinherit` dir is parsed here and re-parsed at activation via
// `config_keywords`; a single-parse pass shared across both is a deferred
// cross-cutting refactor (touches the composition-branch callers)
pub(super) fn participant_dirs(start: &Path) -> Vec<PathBuf> {
let mut cade_chain: Vec<PathBuf> = Vec::new();
let mut nearest_envrc: Option<PathBuf> = None;
Expand All @@ -32,7 +46,11 @@ pub(super) fn participant_dirs(start: &Path) -> Vec<PathBuf> {
while let Some(d) = dir {
match dir_kind(&d) {
Some(DirKind::Cade) => {
// include this dir, then stop the cascade if it disinherits
cade_chain.push(d.clone());
if reads_disinherit(&d) {
break;
}
}
Some(DirKind::Envrc) => {
// only the nearest .envrc
Expand Down Expand Up @@ -190,4 +208,56 @@ mod tests {
assert_eq!(parts(&participant_dirs(&a), &base), vec!["a".to_string()]);
std::fs::remove_dir_all(&base).ok();
}

/// Build a temp tree from (rel-dir, filename, contents) entries.
fn build_tree(spec: &[(&str, &str, &str)], tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static SALT: AtomicU32 = AtomicU32::new(0);
let base = std::env::temp_dir().join(format!(
"cade-{tag}-{}-{}",
std::process::id(),
SALT.fetch_add(1, Ordering::Relaxed)
));
std::fs::remove_dir_all(&base).ok();
for (rel, file, contents) in spec {
let dir = base.join(rel);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(file), contents.as_bytes()).unwrap();
}
base
}

#[test]
fn disinherit_truncates_the_cade_cascade() {
// child .cade disinherits, so its .cade parent never joins the chain.
let base = build_tree(
&[("a", ".cade", ""), ("a/b", ".cade", "disinherit\n")],
"disinherit",
);
let cwd = base.join("a/b");
assert_eq!(
parts(&participant_dirs(&cwd), &base),
vec!["a/b".to_string()]
);
std::fs::remove_dir_all(&base).ok();
}

#[test]
fn disinherit_still_unions_the_nearest_envrc() {
// disinherit drops the parent .cade, but a nearer .envrc still composes.
let base = build_tree(
&[
("a", ".cade", ""),
("a/b", ".cade", "disinherit\n"),
("a/b/c", ".envrc", "export X=1\n"),
],
"disinherit-envrc",
);
let cwd = base.join("a/b/c");
assert_eq!(
parts(&participant_dirs(&cwd), &base),
vec!["a/b/c".to_string(), "a/b".to_string()]
);
std::fs::remove_dir_all(&base).ok();
}
}
2 changes: 2 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub struct CadeLayer {
#[derive(Debug)]
pub enum Keyword {
Pure,
/// stop the `.cade` cascade at this dir; nothing above it loads
Disinherit,
Call(Vec<String>),
Load(Loadable),
Hook(InnerHook),
Expand Down
67 changes: 67 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1749,3 +1749,70 @@ fn s6_colocated_envrc_is_ignored() {
"a co-located .envrc must be ignored when .cade is present: {s}"
);
}

#[test]
fn disinherit_stops_the_parent_cascade() {
// child .cade disinherits -> its parent .cade never composes
let sb = Sandbox::new();
sb.write("a/.cade", "A_CADE=1\n");
let b = sb.dir("a/b");
sb.write("a/b/.cade", "disinherit\nB_CADE=1\n");
sb.allow(&sb.dir("a"));
sb.allow(&b);

let out = sb.enter(&b, &[]);
assert!(out.status.success(), "{out:?}");
let s = stdout(&out);
assert!(s.contains("export B_CADE='1';"), "{s}");
assert!(
!s.contains("A_CADE"),
"disinherit must drop the parent .cade layer: {s}"
);
}

#[test]
fn disinherit_composes_with_nearest_envrc() {
// disinherit truncates the .cade cascade, but a nearer .envrc still joins
let sb = Sandbox::new();
sb.write("a/.cade", "A_CADE=1\n");
sb.write("a/b/.cade", "disinherit\nB_CADE=1\n");
let c = sb.dir("a/b/c");
sb.write("a/b/c/.envrc", "export C_ENVRC=ok\n");
sb.allow(&sb.dir("a/b"));
sb.allow(&c);

let out = sb.enter(&c, &[]);
assert!(out.status.success(), "{out:?}");
let s = stdout(&out);
assert!(s.contains("export B_CADE='1';"), "{s}");
assert!(s.contains("export C_ENVRC='ok';"), "{s}");
assert!(
!s.contains("A_CADE"),
"disinherit must drop the .cade above it: {s}"
);
}

#[test]
fn allow_gap_fill_respects_disinherit_root() {
// the disinherit dir is the chain root; gap-fill never reaches above it
let sb = Sandbox::new();
sb.write("a/.cade", "A_CADE=1\n");
let b = sb.dir("a/b");
sb.write("a/b/.cade", "disinherit\nB_CADE=1\n");
let tip = sb.dir("a/b/tip");
sb.write("a/b/tip/.cade", "TIP_CADE=1\n");

// approve the disinherit root as the base, then the tip
sb.allow(&b);
sb.allow(&tip);

let out = sb.enter(&tip, &[]);
assert!(out.status.success(), "{out:?}");
let s = stdout(&out);
assert!(s.contains("export TIP_CADE='1';"), "{s}");
assert!(s.contains("export B_CADE='1';"), "{s}");
assert!(
!s.contains("A_CADE"),
"disinherit caps the chain, so the parent must never compose: {s}"
);
}
Loading