1use std::path::{Path, PathBuf};
25use std::process::Command;
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
32#[serde(rename_all = "snake_case", tag = "outcome")]
33pub enum EnvrcApproval {
34 Approved,
36 NoEnvrc,
38 DirenvAbsent,
41 Differs,
44 NotApprovedInPrimary,
46 Failed {
48 message: String,
50 },
51}
52
53impl EnvrcApproval {
54 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
79#[serde(rename_all = "snake_case", tag = "outcome")]
80pub enum LocalOverrides {
81 Linked,
84 OwnKept,
86 NonePrimary,
88 NotIgnored,
91 Failed {
93 message: String,
95 },
96}
97
98impl LocalOverrides {
99 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
113pub 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
150pub 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
159pub 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 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
208fn 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 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 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 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 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 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 assert_eq!(link_local(&primary, &wt), LocalOverrides::OwnKept);
311 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 #[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}