release_kit/devshell/
guard.rs1use std::fs;
12use std::io::Write as _;
13use std::path::PathBuf;
14
15use camino::Utf8Path;
16
17use super::{lock_path, stamp_path};
18use crate::maintenance::GIT_HOOK_VARS;
19use crate::probes::git_bin;
20
21const LOCK_GRACE: std::time::Duration = std::time::Duration::from_secs(15 * 60);
23
24pub const CI_VARS: [&str; 6] = [
26 "CI",
27 "GITHUB_ACTIONS",
28 "GITLAB_CI",
29 "BUILDKITE",
30 "CIRCLECI",
31 "TF_BUILD",
32];
33
34pub const SWITCH_VAR: &str = "RK_DEVSHELL_SYNC";
37
38#[must_use]
40pub fn switched_off() -> bool {
41 std::env::var(SWITCH_VAR).is_ok_and(|value| value.trim() == "0")
42}
43
44#[must_use]
46pub fn in_ci() -> bool {
47 CI_VARS.iter().any(|var| {
48 std::env::var(var).is_ok_and(|value| {
49 let value = value.trim().to_ascii_lowercase();
50 !(value.is_empty() || value == "0" || value == "false")
51 })
52 })
53}
54
55#[must_use]
57pub fn today() -> String {
58 crate::applog::now_utc()[..10].to_owned()
59}
60
61pub fn write_stamp(key: &str) -> std::io::Result<()> {
67 let Some(path) = stamp_path(key) else {
68 return Err(std::io::Error::other("no state root for the stamp"));
69 };
70 if let Some(parent) = path.parent() {
71 fs::create_dir_all(parent)?;
72 }
73 crate::atomic::write(&path, format!("{}\n", today()).as_bytes())
74}
75
76#[derive(Debug)]
78pub struct Lock(PathBuf);
79
80impl Drop for Lock {
81 fn drop(&mut self) {
82 let _ = fs::remove_file(&self.0);
83 }
84}
85
86#[derive(Debug)]
88pub enum Acquired {
89 Held(Lock),
91 Contended,
93 Unavailable(std::io::Error),
95}
96
97#[must_use]
101pub fn acquire(key: &str) -> Acquired {
102 let Some(path) = lock_path(key) else {
103 return Acquired::Unavailable(std::io::Error::other(
104 "neither XDG_STATE_HOME nor HOME is set, so the lock has no root",
105 ));
106 };
107 if let Some(parent) = path.parent() {
108 if let Err(source) = fs::create_dir_all(parent) {
109 return Acquired::Unavailable(source);
110 }
111 }
112 match take(&path) {
113 Ok(lock) => Acquired::Held(lock),
114 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
115 let pid = fs::read_to_string(&path).ok().and_then(|text| {
116 text.lines()
117 .find_map(|line| line.strip_prefix("pid=")?.trim().parse::<u64>().ok())
118 });
119 if !super::txn::owner_gone_after(pid, &path, LOCK_GRACE) {
120 return Acquired::Contended;
121 }
122 match fs::remove_file(&path) {
125 Ok(()) => {}
126 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
127 Err(source) => return Acquired::Unavailable(source),
128 }
129 match take(&path) {
130 Ok(lock) => Acquired::Held(lock),
131 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
132 Acquired::Contended
133 }
134 Err(source) => Acquired::Unavailable(source),
135 }
136 }
137 Err(source) => Acquired::Unavailable(source),
138 }
139}
140
141fn take(path: &std::path::Path) -> std::io::Result<Lock> {
143 let mut file = fs::OpenOptions::new()
144 .write(true)
145 .create_new(true)
146 .open(path)?;
147 file.write_all(format!("pid={}\n", std::process::id()).as_bytes())?;
148 file.write_all(format!("started={}\n", crate::applog::now_utc()).as_bytes())?;
149 Ok(Lock(path.to_path_buf()))
150}
151
152#[must_use]
158pub fn two_files_dirty(target: &Utf8Path) -> bool {
159 let mut command = std::process::Command::new(git_bin());
160 for var in GIT_HOOK_VARS {
161 command.env_remove(var);
162 }
163 command
164 .arg("-C")
165 .arg(target.as_std_path())
166 .args(["status", "--porcelain", "--", "flake.nix", "flake.lock"])
167 .output()
168 .map_or(true, |probed| {
169 !probed.status.success() || !probed.stdout.is_empty()
170 })
171}
172
173#[cfg(test)]
174mod tests {
175 #![allow(clippy::expect_used)]
176
177 use super::{Acquired, take};
178
179 #[test]
180 fn the_lock_is_exclusive_and_released_on_drop() {
181 let dir = tempfile::tempdir().expect("a scratch dir exists");
182 let path = dir.path().join("k.lock");
183 let held = take(&path).expect("the first take holds");
184 assert!(path.exists());
185 let second = take(&path).expect_err("the second take refuses");
186 assert_eq!(second.kind(), std::io::ErrorKind::AlreadyExists);
187 drop(held);
188 assert!(!path.exists(), "the lock is removed on drop");
189 let again = take(&path).expect("the lock is free again");
190 assert!(
191 std::fs::read_to_string(&path)
192 .expect("reads")
193 .starts_with("pid="),
194 "the lock names its owner"
195 );
196 drop(again);
197 }
198
199 #[test]
200 fn the_acquired_vocabulary_is_three_states() {
201 let unavailable = Acquired::Unavailable(std::io::Error::other("x"));
202 assert!(matches!(unavailable, Acquired::Unavailable(_)));
203 assert!(matches!(Acquired::Contended, Acquired::Contended));
204 }
205}