Skip to main content

majordomus_cli/worktree/
direnv.rs

1//! direnv's approval of a worktree's `.envrc`, carried to the path the worktree now has.
2//!
3//! direnv approves an `.envrc` by path and content: the same file at a new path is a file it
4//! has not seen, and it refuses to load it until a person runs `direnv allow` there. A
5//! worktree this module creates or moves therefore starts blocked, and the first `cd` into it
6//! is answered with `direnv: error .envrc is blocked` — in a repository with sixty linked
7//! worktrees, the recurring "direnv does not work again". Measured on 2026-09-09: of the 37
8//! worktrees that had an `.envrc`, 36 were blocked and the primary checkout was the one
9//! approved.
10//!
11//! The approval is carried, not granted. The person approved the `.envrc` of the primary
12//! checkout; a worktree whose `.envrc` is byte-for-byte that file gets the same approval at
13//! its own path, at the moment the path comes into being. A worktree whose `.envrc` differs
14//! — an older branch, a branch that is not theirs — is left as direnv leaves it, and the
15//! report says so, because approving content the person has not seen is the one thing
16//! `direnv allow` exists to prevent. When the primary checkout's own `.envrc` is not
17//! approved, nothing is approved anywhere: the person does not load this file, and a
18//! worktree is not the place to start.
19//!
20//! Every outcome is reported and none is fatal. A worktree that exists and is blocked is
21//! still a worktree; what this adds is that a person learns it from the report rather than
22//! from the next `cd`.
23
24use std::path::{Path, PathBuf};
25use std::process::Command;
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29
30/// What became of the `.envrc` of a worktree that was just created, found, or moved.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case", tag = "outcome")]
33pub enum EnvrcApproval {
34    /// direnv accepted it: the next `cd` loads the environment.
35    Approved,
36    /// The worktree has no `.envrc`; there is nothing to approve and nothing is blocked.
37    NoEnvrc,
38    /// direnv is not on the PATH; nothing was done, and nothing is blocked either, because
39    /// nothing would load the file.
40    DirenvAbsent,
41    /// The worktree's `.envrc` is not the primary checkout's, so the approval given there
42    /// does not carry. `direnv allow` in the worktree, after reading it, is the person's.
43    Differs,
44    /// The primary checkout's `.envrc` is not approved, so there is no approval to carry.
45    NotApprovedInPrimary,
46    /// direnv was asked and refused, or could not be run; the message is its own.
47    Failed {
48        /// direnv's standard error, or the error running it.
49        message: String,
50    },
51}
52
53impl EnvrcApproval {
54    /// One line for a person, after the path the worktree is at.
55    pub fn describe(&self) -> String {
56        match self {
57            Self::Approved => {
58                "envrc   approved for direnv (the primary checkout's, at this path)".into()
59            }
60            Self::NoEnvrc => "envrc   none; nothing for direnv to load".into(),
61            Self::DirenvAbsent => "envrc   direnv is not installed; nothing to approve".into(),
62            Self::Differs => {
63                "envrc   differs from the primary checkout's and stays blocked; read it, then \
64                 `direnv allow` there"
65                    .into()
66            }
67            Self::NotApprovedInPrimary => {
68                "envrc   the primary checkout's is not approved, so nothing was approved here; \
69                 `direnv allow` in the primary checkout first"
70                    .into()
71            }
72            Self::Failed { message } => format!("envrc   direnv refused: {message}"),
73        }
74    }
75}
76
77/// What became of the primary checkout's machine-local `.envrc.local` for a worktree.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
79#[serde(rename_all = "snake_case", tag = "outcome")]
80pub enum LocalOverrides {
81    /// Linked: the worktree's `.envrc.local` is a symlink to the primary checkout's, so the
82    /// person's machine-local exports (keychain-backed secrets among them) load here too.
83    Linked,
84    /// The worktree already has its own `.envrc.local`; it is left alone.
85    OwnKept,
86    /// The primary checkout has none; there is nothing to share.
87    NonePrimary,
88    /// Not linked, because git in the worktree does not ignore `.envrc.local` — a link a
89    /// commit could pick up is the one way this could leak.
90    NotIgnored,
91    /// The link could not be made; the error.
92    Failed {
93        /// Why.
94        message: String,
95    },
96}
97
98impl LocalOverrides {
99    /// One line for a person, after the envrc line.
100    pub fn describe(&self) -> String {
101        match self {
102            Self::Linked => "local   .envrc.local linked to the primary checkout's".into(),
103            Self::OwnKept => "local   .envrc.local of its own, kept".into(),
104            Self::NonePrimary => "local   no .envrc.local in the primary checkout to share".into(),
105            Self::NotIgnored => {
106                "local   .envrc.local is not ignored here, so it was not linked".into()
107            }
108            Self::Failed { message } => format!("local   .envrc.local not linked: {message}"),
109        }
110    }
111}
112
113/// Share the primary checkout's `.envrc.local` with `worktree` as a symlink, so the value
114/// lives in one file and a change there reaches every worktree. `.envrc` reads it relative
115/// to the directory it is entered from, and the adapter rule keeps that file free of the
116/// program a path lookup would need; a linked worktree therefore gets the link when it
117/// comes into being. Only when git ignores the name there: a symlink to secrets that a
118/// commit could add is refused rather than made.
119pub fn link_local(primary: &Path, worktree: &Path) -> LocalOverrides {
120    let source = primary.join(".envrc.local");
121    let target = worktree.join(".envrc.local");
122    if target.symlink_metadata().is_ok() {
123        return LocalOverrides::OwnKept;
124    }
125    if !source.is_file() {
126        return LocalOverrides::NonePrimary;
127    }
128    let ignored = Command::new("git")
129        .arg("-C")
130        .arg(worktree)
131        .args(["check-ignore", "-q", ".envrc.local"])
132        .status()
133        .map(|s| s.success())
134        .unwrap_or(false);
135    if !ignored {
136        return LocalOverrides::NotIgnored;
137    }
138    #[cfg(unix)]
139    let made = std::os::unix::fs::symlink(&source, &target);
140    #[cfg(not(unix))]
141    let made: std::io::Result<()> = Err(std::io::Error::other("symlinks are unix-only here"));
142    match made {
143        Ok(()) => LocalOverrides::Linked,
144        Err(e) => LocalOverrides::Failed {
145            message: e.to_string(),
146        },
147    }
148}
149
150/// Carry the primary checkout's approval to the `.envrc` of `worktree`, with the direnv on
151/// the PATH. Never fails: the worst outcome is a worktree left as direnv left it, reported.
152pub fn approve(primary: &Path, worktree: &Path) -> EnvrcApproval {
153    match direnv_on_path() {
154        Some(direnv) => approve_with(&direnv, primary, worktree),
155        None => EnvrcApproval::DirenvAbsent,
156    }
157}
158
159/// The same, with a given direnv executable. What the tests drive, with a direnv of their
160/// own making, so the decision is proved without depending on the machine's.
161pub fn approve_with(direnv: &Path, primary: &Path, worktree: &Path) -> EnvrcApproval {
162    let envrc = worktree.join(".envrc");
163    let Ok(here) = std::fs::read(&envrc) else {
164        return EnvrcApproval::NoEnvrc;
165    };
166    let Ok(there) = std::fs::read(primary.join(".envrc")) else {
167        return EnvrcApproval::Differs;
168    };
169    if here != there {
170        return EnvrcApproval::Differs;
171    }
172    match allowed_in(direnv, primary) {
173        Some(true) => {}
174        Some(false) => return EnvrcApproval::NotApprovedInPrimary,
175        None => {
176            return EnvrcApproval::Failed {
177                message: "`direnv status` in the primary checkout did not say whether its \
178                          .envrc is allowed"
179                    .into(),
180            }
181        }
182    }
183    // The absolute path, and run from the worktree: direnv resolves a bare `allow` against
184    // its working directory, and the absolute argument says which file is meant even so.
185    let output = Command::new(direnv)
186        .arg("allow")
187        .arg(&envrc)
188        .current_dir(worktree)
189        .output();
190    match output {
191        Ok(o) if o.status.success() => EnvrcApproval::Approved,
192        Ok(o) => {
193            let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
194            EnvrcApproval::Failed {
195                message: if stderr.is_empty() {
196                    format!("exit {}", o.status)
197                } else {
198                    stderr
199                },
200            }
201        }
202        Err(e) => EnvrcApproval::Failed {
203            message: e.to_string(),
204        },
205    }
206}
207
208/// Whether direnv has the `.envrc` of `dir` approved, read from `direnv status` run there.
209/// `None` when the answer is not in the output: an unknown is never taken for a yes.
210fn allowed_in(direnv: &Path, dir: &Path) -> Option<bool> {
211    let output = Command::new(direnv)
212        .arg("status")
213        .current_dir(dir)
214        .output()
215        .ok()?;
216    let text = String::from_utf8_lossy(&output.stdout);
217    // `Found RC allowed 0` is allowed; 1 is not allowed; 2 is denied. The line is direnv's,
218    // and has been since the allow/deny split; anything else is an unknown.
219    text.lines()
220        .find_map(|l| l.trim().strip_prefix("Found RC allowed "))
221        .and_then(|v| v.trim().parse::<u8>().ok())
222        .map(|v| v == 0)
223}
224
225fn direnv_on_path() -> Option<PathBuf> {
226    let path = std::env::var_os("PATH")?;
227    std::env::split_paths(&path)
228        .map(|d| d.join("direnv"))
229        .find(|p| p.is_file())
230}
231
232#[cfg(all(test, unix))]
233mod tests {
234    use super::*;
235    use std::os::unix::fs::PermissionsExt;
236
237    /// A direnv of the test's own: answers `status` with the given allowed value and records
238    /// every `allow` it is asked for in a file, one path per line.
239    fn fake_direnv(dir: &Path, allowed: u8, allow_exit: i32) -> (PathBuf, PathBuf) {
240        let record = dir.join("allowed.log");
241        let script = dir.join("direnv");
242        std::fs::write(
243            &script,
244            format!(
245                "#!/bin/sh\ncase \"$1\" in\n  status) printf 'Loaded RC allowed 0\\nFound RC allowed {allowed}\\n' ;;\n  allow) printf '%s\\n' \"$2\" >> '{}'; exit {allow_exit} ;;\n  *) exit 2 ;;\nesac\n",
246                record.display()
247            ),
248        )
249        .unwrap();
250        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
251        // Linux refuses to exec a file that any process holds open for writing (ETXTBSY). A
252        // test thread that forks a child while the write above has the file open leaves that
253        // child holding the descriptor until it execs, so the first exec of this fake could
254        // fail, and `approve_with` read the failure as "direnv did not say" (master's rust job
255        // in run 36557029377). One exec that succeeds proves no writer is left: the write has
256        // closed, and no later fork can inherit it.
257        wait_until_executable(&script);
258        (script, record)
259    }
260
261    fn wait_until_executable(script: &Path) {
262        for _ in 0..100 {
263            match Command::new(script).arg("probe").output() {
264                Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
265                    std::thread::sleep(std::time::Duration::from_millis(20))
266                }
267                _ => return,
268            }
269        }
270        panic!("{} stayed busy for writing", script.display());
271    }
272
273    fn checkout(root: &Path, name: &str, envrc: Option<&str>) -> PathBuf {
274        let dir = root.join(name);
275        std::fs::create_dir_all(&dir).unwrap();
276        if let Some(text) = envrc {
277            std::fs::write(dir.join(".envrc"), text).unwrap();
278        }
279        dir
280    }
281
282    fn git_repo(dir: &Path, ignore: &str) {
283        std::fs::create_dir_all(dir).unwrap();
284        assert!(Command::new("git")
285            .arg("-C")
286            .arg(dir)
287            .args(["init", "-q"])
288            .status()
289            .unwrap()
290            .success());
291        std::fs::write(dir.join(".gitignore"), ignore).unwrap();
292    }
293
294    #[test]
295    fn the_primary_local_overrides_are_linked_only_where_git_ignores_them() {
296        let t = tempfile::tempdir().unwrap();
297        let primary = t.path().join("primary");
298        std::fs::create_dir_all(&primary).unwrap();
299        // nothing to share
300        let wt = t.path().join("wt-a");
301        git_repo(&wt, ".envrc.local\n");
302        assert_eq!(link_local(&primary, &wt), LocalOverrides::NonePrimary);
303        std::fs::write(primary.join(".envrc.local"), "export A=1\n").unwrap();
304        // linked, and the link reads the primary's file
305        assert_eq!(link_local(&primary, &wt), LocalOverrides::Linked);
306        let link = wt.join(".envrc.local");
307        assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
308        assert_eq!(std::fs::read_to_string(&link).unwrap(), "export A=1\n");
309        // a second call keeps what is there
310        assert_eq!(link_local(&primary, &wt), LocalOverrides::OwnKept);
311        // a worktree whose git would track the file gets no link
312        let open = t.path().join("wt-b");
313        git_repo(&open, "");
314        assert_eq!(link_local(&primary, &open), LocalOverrides::NotIgnored);
315        assert!(open.join(".envrc.local").symlink_metadata().is_err());
316    }
317
318    #[test]
319    fn the_primary_checkouts_approval_is_carried_to_an_identical_envrc() {
320        let tmp = tempfile::tempdir().unwrap();
321        let (direnv, record) = fake_direnv(tmp.path(), 0, 0);
322        let primary = checkout(tmp.path(), "repo", Some("PATH_add bin\n"));
323        let worktree = checkout(tmp.path(), "repo-wt/feature/x", Some("PATH_add bin\n"));
324        assert_eq!(
325            approve_with(&direnv, &primary, &worktree),
326            EnvrcApproval::Approved
327        );
328        let asked = std::fs::read_to_string(record).unwrap();
329        assert_eq!(asked.trim(), worktree.join(".envrc").display().to_string());
330    }
331
332    #[test]
333    fn a_different_envrc_is_left_blocked_and_direnv_is_not_asked() {
334        let tmp = tempfile::tempdir().unwrap();
335        let (direnv, record) = fake_direnv(tmp.path(), 0, 0);
336        let primary = checkout(tmp.path(), "repo", Some("PATH_add bin\n"));
337        let worktree = checkout(tmp.path(), "repo-wt/old", Some("eval \"$(something)\"\n"));
338        assert_eq!(
339            approve_with(&direnv, &primary, &worktree),
340            EnvrcApproval::Differs
341        );
342        assert!(!record.exists(), "direnv allow must not have been run");
343    }
344
345    /// Linux alone refuses to exec a file held open for writing, so this holds there: the
346    /// fake is run only once no writer holds it, rather than failing on the first try.
347    #[cfg(target_os = "linux")]
348    #[test]
349    fn a_fake_still_open_for_writing_is_waited_for_before_it_is_run() {
350        let tmp = tempfile::tempdir().unwrap();
351        let (direnv, _) = fake_direnv(tmp.path(), 0, 0);
352        let held = std::fs::OpenOptions::new()
353            .write(true)
354            .open(&direnv)
355            .unwrap();
356        let busy = Command::new(&direnv).arg("probe").output().unwrap_err();
357        assert_eq!(busy.raw_os_error(), Some(libc::ETXTBSY), "{busy}");
358        let release = std::thread::spawn(move || {
359            std::thread::sleep(std::time::Duration::from_millis(200));
360            drop(held);
361        });
362        wait_until_executable(&direnv);
363        let after = Command::new(&direnv).arg("probe").output();
364        release.join().unwrap();
365        assert!(
366            after.is_ok(),
367            "the fake was handed on while still busy: {after:?}"
368        );
369    }
370
371    #[test]
372    fn nothing_is_approved_when_the_primary_checkout_is_not() {
373        let tmp = tempfile::tempdir().unwrap();
374        let (direnv, record) = fake_direnv(tmp.path(), 1, 0);
375        let primary = checkout(tmp.path(), "repo", Some("PATH_add bin\n"));
376        let worktree = checkout(tmp.path(), "repo-wt/feature/x", Some("PATH_add bin\n"));
377        assert_eq!(
378            approve_with(&direnv, &primary, &worktree),
379            EnvrcApproval::NotApprovedInPrimary
380        );
381        assert!(!record.exists(), "direnv allow must not have been run");
382    }
383
384    #[test]
385    fn a_worktree_without_an_envrc_has_nothing_to_approve() {
386        let tmp = tempfile::tempdir().unwrap();
387        let (direnv, _) = fake_direnv(tmp.path(), 0, 0);
388        let primary = checkout(tmp.path(), "repo", Some("PATH_add bin\n"));
389        let worktree = checkout(tmp.path(), "repo-wt/bare", None);
390        assert_eq!(
391            approve_with(&direnv, &primary, &worktree),
392            EnvrcApproval::NoEnvrc
393        );
394    }
395
396    #[test]
397    fn a_refusal_by_direnv_is_reported_not_swallowed() {
398        let tmp = tempfile::tempdir().unwrap();
399        let (direnv, _) = fake_direnv(tmp.path(), 0, 1);
400        let primary = checkout(tmp.path(), "repo", Some("PATH_add bin\n"));
401        let worktree = checkout(tmp.path(), "repo-wt/feature/x", Some("PATH_add bin\n"));
402        match approve_with(&direnv, &primary, &worktree) {
403            EnvrcApproval::Failed { message } => assert!(message.contains("exit"), "{message}"),
404            other => panic!("{other:?}"),
405        }
406    }
407
408    #[test]
409    fn an_unreadable_status_is_an_unknown_never_a_yes() {
410        let tmp = tempfile::tempdir().unwrap();
411        let script = tmp.path().join("direnv");
412        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
413        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
414        let primary = checkout(tmp.path(), "repo", Some("PATH_add bin\n"));
415        let worktree = checkout(tmp.path(), "repo-wt/feature/x", Some("PATH_add bin\n"));
416        assert!(matches!(
417            approve_with(&script, &primary, &worktree),
418            EnvrcApproval::Failed { .. }
419        ));
420    }
421
422    #[test]
423    fn every_outcome_describes_itself_on_one_line_starting_with_the_field() {
424        for outcome in [
425            EnvrcApproval::Approved,
426            EnvrcApproval::NoEnvrc,
427            EnvrcApproval::DirenvAbsent,
428            EnvrcApproval::Differs,
429            EnvrcApproval::NotApprovedInPrimary,
430            EnvrcApproval::Failed {
431                message: "x".into(),
432            },
433        ] {
434            let line = outcome.describe();
435            assert!(line.starts_with("envrc   "), "{line}");
436            assert!(!line.contains('\n'), "{line}");
437        }
438    }
439}