Skip to main content

forge_server/
work.rs

1//! The working tree the HOME's rows read, behind a cache: no render path
2//! shells out.
3//!
4//! The session page's own row is not here. That one is
5//! [`forge_workspace::work`]'s: the scan runs for a seat somebody is showing,
6//! and the row is pushed rather than read. This cache is the fleet's - every
7//! project and agent row reads a branch and a change count as it renders -
8//! and a row is not worth a `git` subprocess per render.
9
10use 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
18// The row a view draws, and the gate that qualifies it, live where the
19// session page's scan does: one shape for both readers, and one place the
20// shape is decided.
21pub use forge_workspace::work::{Gate, WorkState, git_work_view, work_from_scan};
22
23/// How long a read answers for. Everything inside the window is served
24/// from the cache, which is what keeps a page render off a subprocess.
25const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
26
27/// Each session's working tree, so a caller reads a value instead of
28/// spawning `git` per row per render.
29pub struct WorkCache {
30    entries: Mutex<HashMap<SessionSlot, Entry>>,
31}
32
33struct Entry {
34    cwd: PathBuf,
35    /// The last read, `None` until the first one lands.
36    state: Option<WorkState>,
37    read_at: Instant,
38    /// Held across a refresh so two callers for one slot do not both probe
39    /// the same tree.
40    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    /// The cached read, when it was taken of this same directory and is
54    /// still inside the window.
55    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    /// `cwd` as an agent's working tree, at most `REFRESH_INTERVAL` old.
76    /// A read outside the window goes to git; one inside answers from the
77    /// cache. A moved cwd - a `/new`, a worktree - is a different tree and
78    /// is read afresh.
79    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        // One refresh per slot: a second caller waits here, then finds the
85        // read the first one has already stored.
86        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    /// A panicking task must not take the cache with it: the map holds no
103    /// invariant a panic can break.
104    fn entries(&self) -> MutexGuard<'_, HashMap<SessionSlot, Entry>> {
105        self.entries.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
106    }
107}
108
109/// One read of `cwd`. The count decides whether the directory is a
110/// repository at all: outside one both fields are `None` rather than an
111/// error or a stale branch, and the gate carries which of the two cases it
112/// was so the row can say so.
113async 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            // Git calls a missing directory "not a repository", so the
122            // path itself decides between the two: the row can say a
123            // worktree is gone, and it cannot say a project is not a
124            // repository without looking.
125            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    /// Spawn git the way the product does, scrub included. The product's
144    /// constructor is `env::git_command::command`, which removes the
145    /// ambient repo-location variables before every spawn; a fixture that
146    /// skips that answers about a foreign repository when the suite runs
147    /// under a git hook, which is the bug the scrub exists for.
148    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    /// A repo with one commit on `main`. `git init -b` needs git 2.28 and
162    /// CI runs 2.25, so HEAD is pointed by `symbolic-ref` instead.
163    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    /// A directory that is not a repository answers with nothing at all,
177    /// never an error and never a stale branch, and says which of the two
178    /// it was: `dotfiles` and a despawned worker's worktree both land here,
179    /// and the row renders an empty `where` plus the line that says why.
180    #[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    /// A path that is not there at all is its own case, not "not a
197    /// repository": git reports both the same way, and only the path can
198    /// tell them apart. A despawned worker's worktree is the usual one.
199    #[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        // And a directory that IS there with no repository is the other
214        // case, so the two cannot be the same arm.
215        let state = cache.snapshot(&slot(), dir.path()).await;
216        assert_eq!(state.gate, Gate::NotARepository);
217    }
218
219    /// A path git cannot answer about at all is the scanner's failure,
220    /// not the project's. A file in place of a directory is the cheapest
221    /// one to make: the repo-existence probe looks for `.git` under it,
222    /// finds a path that cannot hold one, and cannot rule a checkout out.
223    #[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    /// A repository answers its branch and how much has moved in it.
240    #[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    /// The second read inside the window is served from the cache rather
258    /// than re-probed, which is what keeps a page render off the
259    /// subprocess. The file is changed between the two reads, so a cache
260    /// that re-read every time would report the new count.
261    #[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}