Skip to main content

gobby_code/index/indexer/
freshness_probe.rs

1//! Lock-free, hash-free freshness pre-gate.
2//!
3//! `project_changed_since` answers one question without taking the per-project
4//! advisory lock and without hashing any file: has anything under the project
5//! root changed since the recorded `last_indexed_at`? Read-time freshness calls
6//! this *before* the lock so the common no-change case is cheap and never prints
7//! "refresh already running". When it reports a change, the caller falls through
8//! to the existing lock + incremental reconcile, which is exactly as correct as
9//! before.
10
11use std::path::Path;
12use std::time::{Duration, SystemTime};
13
14use crate::index::walker;
15
16use super::util::DEFAULT_EXCLUDES;
17
18/// Clock-skew / mtime-granularity margin. Subtracted from `last_indexed_at`
19/// before comparing file mtimes, so the gate only ever errs toward refreshing
20/// and can never miss a real change. Absorbs host-vs-PostgreSQL (docker) clock
21/// skew and same-second mtime granularity. It is not a distributed-filesystem
22/// correctness guarantee; larger NFS or multi-host clock drift is reconciled by
23/// the periodic maintenance full re-hash sweep.
24const SKEW_MARGIN: Duration = Duration::from_secs(2);
25
26/// Returns `true` if any discovered file is newer than `last_indexed_at` (a
27/// modify or add) or any previously indexed path no longer exists on disk (a
28/// delete or rename), and `false` only when the on-disk tree still matches the
29/// recorded index. A `false` result lets the caller skip the advisory lock and
30/// the full re-hash entirely.
31///
32/// Discovery mirrors the indexer (`walker::discover_files` with
33/// `DEFAULT_EXCLUDES`), so the `.gobby/plans/**/*.md` allowlist and every other
34/// exclusion stay in lockstep with what actually gets indexed — including the
35/// internal `.gobby/plans/*.md` edits the daemon trigger never forwards.
36/// Short-circuits on the first sign of change.
37pub fn project_changed_since(
38    project_root: &Path,
39    last_indexed_at: SystemTime,
40    indexed_paths: &[String],
41    options: walker::DiscoveryOptions,
42) -> bool {
43    let threshold = last_indexed_at
44        .checked_sub(SKEW_MARGIN)
45        .unwrap_or(last_indexed_at);
46
47    let (candidates, content_only) =
48        walker::discover_files_with_options(project_root, DEFAULT_EXCLUDES, options);
49
50    // Modify / add: a discovered file whose mtime is newer than the threshold.
51    // A freshly added file also carries a recent mtime, so adds are caught here
52    // without a fragile path-set diff. An unreadable mtime is treated as a
53    // change, so we never skip a refresh for a file we cannot stat.
54    for path in candidates.iter().chain(content_only.iter()) {
55        match path.metadata() {
56            Ok(meta) => match meta.modified() {
57                Ok(modified) if modified <= threshold => {}
58                Ok(_) => return true,
59                Err(error) => {
60                    log::debug!(
61                        "treating project as changed: failed to read mtime for {}: {error}",
62                        path.display()
63                    );
64                    return true;
65                }
66            },
67            Err(error) => {
68                log::debug!(
69                    "treating project as changed: failed to read metadata for {}: {error}",
70                    path.display()
71                );
72                return true;
73            }
74        }
75    }
76
77    // Delete / rename: a path recorded in the index that is gone from disk.
78    indexed_paths
79        .iter()
80        .any(|rel| !project_root.join(rel).exists())
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use std::fs::File;
87    use std::path::PathBuf;
88
89    fn write_file(root: &Path, rel: &str, contents: &[u8]) -> PathBuf {
90        let path = root.join(rel);
91        if let Some(parent) = path.parent() {
92            std::fs::create_dir_all(parent).expect("create parent");
93        }
94        std::fs::write(&path, contents).expect("write file");
95        path
96    }
97
98    fn set_mtime(path: &Path, time: SystemTime) {
99        File::options()
100            .write(true)
101            .open(path)
102            .expect("open file to set mtime")
103            .set_modified(time)
104            .expect("set mtime");
105    }
106
107    /// A fixed, whole-second base instant well in the past, so the arithmetic
108    /// never underflows and 1-second-granularity filesystems round-trip it.
109    fn base_time() -> SystemTime {
110        SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
111    }
112
113    fn default_options() -> walker::DiscoveryOptions {
114        walker::DiscoveryOptions::default()
115    }
116
117    #[test]
118    fn reports_no_change_when_everything_predates_last_index() {
119        let tmp = tempfile::tempdir().expect("tempdir");
120        let root = tmp.path();
121        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
122        let readme = write_file(root, "README.md", b"# Title\n");
123
124        let base = base_time();
125        set_mtime(&lib, base);
126        set_mtime(&readme, base);
127
128        // last_indexed_at is well after every file's mtime.
129        let last = base + Duration::from_secs(3600);
130        let indexed = vec!["src/lib.rs".to_string(), "README.md".to_string()];
131
132        assert!(!project_changed_since(
133            root,
134            last,
135            &indexed,
136            default_options()
137        ));
138    }
139
140    #[test]
141    fn reports_change_when_a_file_is_modified_after_last_index() {
142        let tmp = tempfile::tempdir().expect("tempdir");
143        let root = tmp.path();
144        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
145        set_mtime(&lib, base_time() + Duration::from_secs(7200));
146
147        let last = base_time() + Duration::from_secs(3600);
148        let indexed = vec!["src/lib.rs".to_string()];
149
150        assert!(project_changed_since(
151            root,
152            last,
153            &indexed,
154            default_options()
155        ));
156    }
157
158    #[test]
159    fn reports_change_for_newly_added_file() {
160        // A new (unindexed) file carries a recent mtime, so the modify/add scan
161        // trips even though it is absent from indexed_paths.
162        let tmp = tempfile::tempdir().expect("tempdir");
163        let root = tmp.path();
164        let added = write_file(root, "src/new.rs", b"fn added() {}\n");
165        set_mtime(&added, base_time() + Duration::from_secs(7200));
166
167        let last = base_time() + Duration::from_secs(3600);
168        let indexed: Vec<String> = Vec::new();
169
170        assert!(project_changed_since(
171            root,
172            last,
173            &indexed,
174            default_options()
175        ));
176    }
177
178    #[test]
179    fn reports_change_when_indexed_file_is_deleted() {
180        let tmp = tempfile::tempdir().expect("tempdir");
181        let root = tmp.path();
182        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
183        set_mtime(&lib, base_time());
184
185        let last = base_time() + Duration::from_secs(3600);
186        // "src/gone.rs" is recorded as indexed but no longer exists on disk.
187        let indexed = vec!["src/lib.rs".to_string(), "src/gone.rs".to_string()];
188
189        assert!(project_changed_since(
190            root,
191            last,
192            &indexed,
193            default_options()
194        ));
195    }
196
197    #[test]
198    fn skew_margin_boundary_only_ever_makes_the_gate_more_eager() {
199        let tmp = tempfile::tempdir().expect("tempdir");
200        let root = tmp.path();
201        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
202        let mtime = base_time();
203        set_mtime(&lib, mtime);
204        let indexed = vec!["src/lib.rs".to_string()];
205
206        // File is 1s older than last_indexed_at — inside the 2s margin, so the
207        // gate refreshes (threshold = last - 2s = mtime - 1s < mtime).
208        let within_margin = mtime + Duration::from_secs(1);
209        assert!(project_changed_since(
210            root,
211            within_margin,
212            &indexed,
213            default_options()
214        ));
215
216        // File sits exactly at the boundary (threshold == mtime, mtime <=
217        // threshold), so it counts as unchanged.
218        let at_margin = mtime + SKEW_MARGIN;
219        assert!(!project_changed_since(
220            root,
221            at_margin,
222            &indexed,
223            default_options()
224        ));
225
226        // File is 3s older than last_indexed_at — beyond the 2s margin, so the
227        // gate skips (threshold = last - 2s = mtime + 1s >= mtime).
228        let beyond_margin = mtime + Duration::from_secs(3);
229        assert!(!project_changed_since(
230            root,
231            beyond_margin,
232            &indexed,
233            default_options()
234        ));
235    }
236
237    #[test]
238    fn gitignored_new_files_follow_respect_gitignore_setting() {
239        let tmp = tempfile::tempdir().expect("tempdir");
240        let root = tmp.path();
241        std::fs::create_dir(root.join(".git")).expect("git dir");
242        write_file(root, ".gitignore", b"ignored.rs\n");
243        let ignored = write_file(root, "ignored.rs", b"fn ignored() {}\n");
244        set_mtime(&ignored, base_time() + Duration::from_secs(7200));
245
246        let last = base_time() + Duration::from_secs(3600);
247        let indexed: Vec<String> = Vec::new();
248
249        assert!(!project_changed_since(
250            root,
251            last,
252            &indexed,
253            walker::DiscoveryOptions {
254                respect_gitignore: true
255            }
256        ));
257        assert!(project_changed_since(
258            root,
259            last,
260            &indexed,
261            walker::DiscoveryOptions {
262                respect_gitignore: false
263            }
264        ));
265    }
266}