1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, Mutex, MutexGuard};
13use std::time::{Duration, Instant};
14
15use crate::git_diff;
16use forge_primitives::SessionSlot;
17
18pub use forge_workspace::work::{Gate, WorkState, git_work_view, work_from_scan};
22
23const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
26
27pub struct WorkCache {
30 entries: Mutex<HashMap<SessionSlot, Entry>>,
31}
32
33struct Entry {
34 cwd: PathBuf,
35 state: Option<WorkState>,
37 read_at: Instant,
38 refreshing: Arc<tokio::sync::Mutex<()>>,
41}
42
43impl Entry {
44 fn new(cwd: &Path) -> Self {
45 Self {
46 cwd: cwd.to_owned(),
47 state: None,
48 read_at: Instant::now(),
49 refreshing: Arc::default(),
50 }
51 }
52
53 fn answers_for(&self, cwd: &Path) -> Option<&WorkState> {
56 if self.cwd == cwd && self.read_at.elapsed() < REFRESH_INTERVAL {
57 self.state.as_ref()
58 } else {
59 None
60 }
61 }
62}
63
64impl Default for WorkCache {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl WorkCache {
71 pub fn new() -> Self {
72 Self { entries: Mutex::new(HashMap::new()) }
73 }
74
75 pub async fn snapshot(&self, slot: &SessionSlot, cwd: &Path) -> WorkState {
80 let refreshing = {
81 let mut entries = self.entries();
82 Arc::clone(&entries.entry(slot.clone()).or_insert_with(|| Entry::new(cwd)).refreshing)
83 };
84 let _refreshing = refreshing.lock().await;
87 {
88 let entries = self.entries();
89 if let Some(state) = entries.get(slot).and_then(|entry| entry.answers_for(cwd)) {
90 return state.clone();
91 }
92 }
93 let state = read(cwd).await;
94 let mut entries = self.entries();
95 let entry = entries.entry(slot.clone()).or_insert_with(|| Entry::new(cwd));
96 cwd.clone_into(&mut entry.cwd);
97 entry.state = Some(state.clone());
98 entry.read_at = Instant::now();
99 state
100 }
101
102 fn entries(&self) -> MutexGuard<'_, HashMap<SessionSlot, Entry>> {
105 self.entries.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
106 }
107}
108
109async fn read(cwd: &Path) -> WorkState {
114 let (branch, changed) =
115 tokio::join!(git_diff::current_branch(cwd), git_diff::changed_file_count(cwd));
116 match changed {
117 Ok(changed) => {
118 WorkState { branch: branch.ok().flatten(), changed: Some(changed), gate: Gate::InRepo }
119 }
120 Err(gate) => {
121 let gate = if cwd.exists() { gate.into() } else { Gate::Gone };
126 WorkState { branch: None, changed: None, gate }
127 }
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use std::path::Path;
134
135 use forge_primitives::SessionSlot;
136
137 use crate::work::{Gate, WorkCache};
138
139 fn slot() -> SessionSlot {
140 SessionSlot::lead("TestOrg", "forge")
141 }
142
143 fn git(dir: &Path, args: &[&str]) {
149 let out = std::process::Command::new("git")
150 .env_remove("GIT_DIR")
151 .env_remove("GIT_WORK_TREE")
152 .env_remove("GIT_COMMON_DIR")
153 .arg("-C")
154 .arg(dir)
155 .args(args)
156 .output()
157 .expect("run git");
158 assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
159 }
160
161 fn repo() -> tempfile::TempDir {
164 let dir = tempfile::tempdir().expect("tempdir");
165 git(dir.path(), &["init", "-q"]);
166 git(dir.path(), &["symbolic-ref", "HEAD", "refs/heads/main"]);
167 git(dir.path(), &["config", "user.email", "t@e.com"]);
168 git(dir.path(), &["config", "user.name", "T"]);
169 git(dir.path(), &["config", "commit.gpgsign", "false"]);
170 std::fs::write(dir.path().join("tracked.txt"), "one\n").expect("write");
171 git(dir.path(), &["add", "tracked.txt"]);
172 git(dir.path(), &["commit", "-qm", "init"]);
173 dir
174 }
175
176 #[tokio::test]
181 async fn a_directory_outside_a_repository_answers_with_nothing() {
182 let dir = tempfile::tempdir().expect("tempdir");
183 let cache = WorkCache::new();
184
185 let state = cache.snapshot(&slot(), dir.path()).await;
186
187 assert_eq!(state.branch, None, "a directory outside a repo has no branch");
188 assert_eq!(state.changed, None, "and no changed-file count");
189 assert_eq!(
190 state.gate,
191 Gate::NotARepository,
192 "and the gate says it is the project rather than the scanner",
193 );
194 }
195
196 #[tokio::test]
200 async fn a_path_that_is_gone_is_not_a_repository_that_is_absent() {
201 let dir = tempfile::tempdir().expect("tempdir");
202 let gone = dir.path().join("worktree-that-was-despawned");
203 let cache = WorkCache::new();
204
205 let state = cache.snapshot(&slot(), &gone).await;
206
207 assert_eq!(
208 state.gate,
209 Gate::Gone,
210 "a missing path is gone, not a directory without a repository",
211 );
212
213 let state = cache.snapshot(&slot(), dir.path()).await;
216 assert_eq!(state.gate, Gate::NotARepository);
217 }
218
219 #[tokio::test]
224 async fn a_path_git_cannot_answer_about_is_the_scanners_failure() {
225 let dir = tempfile::tempdir().expect("tempdir");
226 let file = dir.path().join("not-a-directory");
227 std::fs::write(&file, "x\n").expect("write");
228 let cache = WorkCache::new();
229
230 let state = cache.snapshot(&slot(), &file).await;
231
232 assert_eq!(
233 state.gate,
234 Gate::ScannerFailed,
235 "git would not run there, which is forge's problem rather than the project's",
236 );
237 }
238
239 #[tokio::test]
241 async fn a_repository_answers_its_branch_and_its_changed_files() {
242 let dir = repo();
243 std::fs::write(dir.path().join("tracked.txt"), "two\n").expect("modify");
244 std::fs::write(dir.path().join("untracked.txt"), "new\n").expect("add");
245 let cache = WorkCache::new();
246
247 let state = cache.snapshot(&slot(), dir.path()).await;
248
249 assert_eq!(state.branch.as_deref(), Some("main"), "the row shows the branch it is on");
250 assert_eq!(
251 state.changed,
252 Some(2),
253 "the count is the one modification plus the one untracked file",
254 );
255 }
256
257 #[tokio::test]
262 async fn a_second_read_inside_the_window_is_served_from_the_cache() {
263 let dir = repo();
264 let cache = WorkCache::new();
265
266 let first = cache.snapshot(&slot(), dir.path()).await;
267 assert_eq!(first.changed, Some(0), "precondition: the tree starts clean");
268
269 std::fs::write(dir.path().join("untracked.txt"), "new\n").expect("add");
270 let second = cache.snapshot(&slot(), dir.path()).await;
271
272 assert_eq!(
273 second.changed,
274 Some(0),
275 "a read inside the refresh window answers what the cache holds",
276 );
277 }
278}