lean_ctx/core/savings_ledger/
project_scope.rs1use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use std::sync::Mutex;
15use std::time::{Duration, Instant};
16
17use super::SavingsEvent;
18
19const SCOPE_TTL: Duration = Duration::from_secs(30);
21const SCOPE_CACHE_LIMIT: usize = 8;
22
23static SCOPES: Mutex<Vec<(PathBuf, Instant, ProjectScope)>> = Mutex::new(Vec::new());
24
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct ProjectScope {
28 project: PathBuf,
29 repo_hashes: HashSet<String>,
30 sessions: HashSet<String>,
31}
32
33impl ProjectScope {
34 pub fn for_project(project: &Path) -> Self {
37 let project = crate::core::pathutil::safe_canonicalize_or_self(project);
38 let mut cache = SCOPES
39 .lock()
40 .unwrap_or_else(std::sync::PoisonError::into_inner);
41 cache.retain(|(_, at, _)| at.elapsed() < SCOPE_TTL);
42 if let Some((_, _, scope)) = cache.iter().find(|(root, _, _)| *root == project) {
43 return scope.clone();
44 }
45 let sessions = crate::core::session::SessionState::list_sessions();
46 let scope = Self::from_sessions(
47 &project,
48 sessions
49 .iter()
50 .filter_map(|s| s.project_root.as_deref().map(|root| (s.id.as_str(), root))),
51 );
52 if cache.len() >= SCOPE_CACHE_LIMIT {
53 cache.remove(0);
54 }
55 cache.push((project, Instant::now(), scope.clone()));
56 scope
57 }
58
59 pub fn from_sessions<'a>(
61 project: &Path,
62 sessions: impl Iterator<Item = (&'a str, &'a str)>,
63 ) -> Self {
64 let project = crate::core::pathutil::safe_canonicalize_or_self(project);
65 let mut scope = Self {
66 repo_hashes: HashSet::from([super::hash_folder(&project.to_string_lossy())]),
67 project,
68 sessions: HashSet::new(),
69 };
70 for (id, folder) in sessions {
71 let raw = Path::new(folder);
72 if !raw.is_absolute() {
73 continue;
74 }
75 let canonical = crate::core::pathutil::safe_canonicalize_or_self(raw);
76 if !belongs(&scope.project, &canonical) {
77 continue;
78 }
79 scope.repo_hashes.insert(super::hash_folder(folder));
81 scope
82 .repo_hashes
83 .insert(super::hash_folder(&canonical.to_string_lossy()));
84 if !id.is_empty() {
85 scope.sessions.insert(id.to_string());
86 }
87 }
88 scope
89 }
90
91 pub fn project(&self) -> &Path {
93 &self.project
94 }
95
96 pub fn repo_hashes(&self) -> Vec<String> {
98 let mut hashes: Vec<String> = self.repo_hashes.iter().cloned().collect();
99 hashes.sort();
100 hashes
101 }
102
103 pub fn sessions(&self) -> &HashSet<String> {
105 &self.sessions
106 }
107
108 pub fn holds_event(&self, event: &SavingsEvent) -> bool {
109 self.holds(&event.repo_hash, event.session_id.as_deref())
110 }
111
112 pub fn holds(&self, repo_hash: &str, session: Option<&str>) -> bool {
114 self.repo_hashes.contains(repo_hash) || session.is_some_and(|id| self.sessions.contains(id))
115 }
116
117 pub fn holds_session(&self, session: Option<&str>) -> bool {
120 session.is_some_and(|id| self.sessions.contains(id))
121 }
122}
123
124fn belongs(project: &Path, folder: &Path) -> bool {
128 folder == project || project_root_of(folder) == project
129}
130
131pub fn project_root_of(folder: &Path) -> PathBuf {
135 let folder = crate::core::pathutil::safe_canonicalize_or_self(folder);
136 let top = folder
137 .ancestors()
138 .take_while(|dir| !crate::core::pathutil::is_broad_or_unsafe_root(dir))
139 .find(|dir| dir.join(".git").exists());
140 match top {
141 Some(top) => main_checkout(top).unwrap_or_else(|| top.to_path_buf()),
142 None => folder,
143 }
144}
145
146fn main_checkout(top: &Path) -> Option<PathBuf> {
150 let text = std::fs::read_to_string(top.join(".git")).ok()?;
151 let git_dir = top.join(text.trim().strip_prefix("gitdir:")?.trim());
152 let git_dir = crate::core::pathutil::safe_canonicalize_or_self(&git_dir);
153 let worktrees = git_dir.parent()?;
154 let dot_git = worktrees.parent()?;
155 (worktrees.file_name()? == "worktrees" && dot_git.file_name()? == ".git")
156 .then(|| dot_git.parent().map(Path::to_path_buf))
157 .flatten()
158}
159
160pub fn project_key_of(folder: &Path) -> Option<String> {
164 static KEYS: Mutex<Vec<(PathBuf, Option<String>)>> = Mutex::new(Vec::new());
165 const KEY_CACHE_LIMIT: usize = 1024;
166 let mut keys = KEYS
167 .lock()
168 .unwrap_or_else(std::sync::PoisonError::into_inner);
169 if let Some((_, key)) = keys.iter().find(|(known, _)| known == folder) {
170 return key.clone();
171 }
172 let key = (folder.is_absolute() && !crate::core::pathutil::is_broad_or_unsafe_root(folder))
173 .then(|| project_root_of(folder))
174 .filter(|root| !crate::core::pathutil::is_broad_or_unsafe_root(root))
175 .map(|root| super::hash_folder(&root.to_string_lossy()));
176 if keys.len() >= KEY_CACHE_LIMIT {
177 keys.remove(0);
178 }
179 keys.push((folder.to_path_buf(), key.clone()));
180 key
181}
182
183pub fn current_project_key() -> Option<String> {
186 let folder = crate::core::policy::runtime::REQUEST_PROJECT
187 .try_with(|slot| slot.borrow().clone())
188 .ok()
189 .flatten()
190 .filter(|p| !p.as_os_str().is_empty())
191 .or_else(|| std::env::current_dir().ok())?;
192 project_key_of(&folder)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 fn repo(dir: &Path) {
200 std::fs::create_dir_all(dir.join(".git")).unwrap();
201 }
202
203 #[test]
204 fn subfolders_and_worktrees_count_but_nested_and_foreign_repositories_do_not() {
205 let tmp = tempfile::tempdir().unwrap();
206 let base = crate::core::pathutil::safe_canonicalize_or_self(tmp.path());
207 let project = base.join("app");
208 repo(&project);
209 let sub = project.join("src/deep");
210 std::fs::create_dir_all(&sub).unwrap();
211 let nested = project.join("website");
212 repo(&nested);
213 let other = base.join("other");
214 repo(&other);
215 let outside = base.join("app-wt");
217 let inside = project.join(".claude/worktrees/w1");
218 for (wt, name) in [(&outside, "wt"), (&inside, "w1")] {
219 std::fs::create_dir_all(wt).unwrap();
220 std::fs::create_dir_all(project.join(".git/worktrees").join(name)).unwrap();
221 let gitdir = project.join(".git/worktrees").join(name);
222 std::fs::write(wt.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap();
223 }
224 let gone = project.join(".codex-worktrees/removed");
225
226 let folders = [
227 ("s-root", project.to_string_lossy().into_owned()),
228 ("s-sub", sub.to_string_lossy().into_owned()),
229 ("s-outside", outside.to_string_lossy().into_owned()),
230 ("s-inside", inside.to_string_lossy().into_owned()),
231 ("s-gone", gone.to_string_lossy().into_owned()),
232 ("s-nested", nested.to_string_lossy().into_owned()),
233 ("s-other", other.to_string_lossy().into_owned()),
234 ("s-relative", "app".to_string()),
235 ];
236 let scope =
237 ProjectScope::from_sessions(&project, folders.iter().map(|(id, f)| (*id, f.as_str())));
238
239 for (id, folder) in &folders[..5] {
240 assert!(scope.holds_session(Some(id)), "{id} belongs to the project");
241 assert!(scope.holds(&crate::core::savings_ledger::hash_folder(folder), None));
242 }
243 for (id, folder) in &folders[5..] {
244 assert!(!scope.holds_session(Some(id)), "{id} is another project");
245 assert!(!scope.holds(&crate::core::savings_ledger::hash_folder(folder), None));
246 }
247 assert!(!scope.holds_session(None));
248 }
249
250 #[test]
251 fn an_event_counts_by_its_folder_or_by_its_session() {
252 let tmp = tempfile::tempdir().unwrap();
253 let project = crate::core::pathutil::safe_canonicalize_or_self(tmp.path()).join("p");
254 repo(&project);
255 let root = project.to_string_lossy().into_owned();
256 let scope = ProjectScope::from_sessions(&project, [("s1", root.as_str())].into_iter());
257 assert!(scope.holds("unrelated", Some("s1")));
258 assert!(!scope.holds("unrelated", Some("s2")));
259 assert!(!scope.holds("unrelated", None));
260 }
261}