Skip to main content

gobby_code/
freshness.rs

1use std::path::{Path, PathBuf};
2use std::time::SystemTime;
3
4use crate::config::Context;
5use crate::db;
6use crate::index::{api, hasher};
7use crate::index_lock::{self, IndexLockPolicy, IndexLockResult};
8use crate::models::Symbol;
9use crate::visibility;
10
11const INFLIGHT_ENV: &str = "GCODE_FRESHNESS_INFLIGHT";
12
13pub enum FreshnessScope {
14    Project,
15    Files(Vec<PathBuf>),
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FreshnessStatus {
20    Checked,
21    SkippedBusy,
22}
23
24pub fn ensure_fresh(ctx: &Context, scope: FreshnessScope) -> anyhow::Result<FreshnessStatus> {
25    if std::env::var_os(INFLIGHT_ENV).is_some() {
26        return Ok(FreshnessStatus::Checked);
27    }
28
29    // Lock-free pre-gate for whole-project reads: if nothing on disk is newer
30    // than the recorded index and nothing was deleted, skip the advisory lock
31    // and the full re-hash entirely (no "refresh already running" warning).
32    // `FreshnessScope::Files` is already cheap (explicit-files path) and is left
33    // untouched.
34    if matches!(scope, FreshnessScope::Project) && !project_needs_refresh(ctx)? {
35        return Ok(FreshnessStatus::Checked);
36    }
37
38    let _guard = FreshnessGuard::enter();
39    let result =
40        index_lock::with_project_lock(ctx, IndexLockPolicy::brief_freshness_try(), || {
41            match scope {
42                FreshnessScope::Project => {
43                    api::index_files(
44                        api::IndexRequest {
45                            project_root: ctx.project_root.clone(),
46                            path_filter: None,
47                            explicit_files: Vec::new(),
48                            full: false,
49                            require_cpp_semantics: false,
50                            sync_projections: false,
51                        },
52                        ctx,
53                    )?;
54                }
55                FreshnessScope::Files(paths) => {
56                    let files: Vec<PathBuf> = paths
57                        .iter()
58                        .map(|path| normalize_file_path(&ctx.project_root, path))
59                        .map(PathBuf::from)
60                        .collect();
61                    if !files.is_empty() {
62                        api::index_files(
63                            api::IndexRequest {
64                                project_root: ctx.project_root.clone(),
65                                path_filter: None,
66                                explicit_files: files,
67                                full: false,
68                                require_cpp_semantics: false,
69                                sync_projections: false,
70                            },
71                            ctx,
72                        )?;
73                    }
74                }
75            }
76            Ok(())
77        })?;
78
79    match result {
80        IndexLockResult::Acquired(()) => Ok(FreshnessStatus::Checked),
81        IndexLockResult::Busy => Ok(FreshnessStatus::SkippedBusy),
82    }
83}
84
85/// Read-only pre-gate for whole-project freshness.
86///
87/// Returns `true` when the project must be reconciled under the advisory lock —
88/// because it has never been indexed or because the on-disk tree changed since
89/// `last_indexed_at` — and `false` when the recorded index is already current
90/// and the lock (plus the full re-hash) can be skipped. Reads only; needs the
91/// hub exactly like the existing refresh path, and propagates a hub error the
92/// same way (`--no-freshness` still bypasses it upstream).
93fn project_needs_refresh(ctx: &Context) -> anyhow::Result<bool> {
94    let mut conn = db::connect_readonly(&ctx.database_url)?;
95
96    let last_indexed_at: Option<SystemTime> = match conn.query_opt(
97        "SELECT last_indexed_at FROM code_indexed_projects WHERE id = $1",
98        &[&ctx.project_id],
99    )? {
100        Some(row) => row.try_get::<_, Option<SystemTime>>(0)?,
101        None => None,
102    };
103
104    // Never indexed (or no recorded timestamp): do not gate; let the existing
105    // refresh path build the first index.
106    let Some(last_indexed_at) = last_indexed_at else {
107        return Ok(true);
108    };
109
110    let indexed_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;
111    drop(conn);
112
113    Ok(api::project_changed_since(
114        &ctx.project_root,
115        last_indexed_at,
116        &indexed_paths,
117        crate::index::walker::DiscoveryOptions {
118            respect_gitignore: ctx.indexing.respect_gitignore,
119        },
120    ))
121}
122
123pub fn ensure_symbol_fresh(ctx: &Context, id: &str) -> anyhow::Result<FreshnessStatus> {
124    if std::env::var_os(INFLIGHT_ENV).is_some() {
125        return Ok(FreshnessStatus::Checked);
126    }
127
128    let mut conn = db::connect_readonly(&ctx.database_url)?;
129    let sym = visibility::visible_symbol_by_id(&mut conn, ctx, id)?;
130    drop(conn);
131
132    let Some(sym) = sym else {
133        return Ok(FreshnessStatus::Checked);
134    };
135
136    if symbol_slice_is_current(ctx, &sym) {
137        return Ok(FreshnessStatus::Checked);
138    }
139
140    ensure_fresh(
141        ctx,
142        FreshnessScope::Files(vec![PathBuf::from(&sym.file_path)]),
143    )
144}
145
146fn symbol_slice_is_current(ctx: &Context, sym: &Symbol) -> bool {
147    if sym.content_hash.is_empty() {
148        return false;
149    }
150
151    let file_path = ctx.project_root.join(&sym.file_path);
152    let source = match std::fs::read(file_path) {
153        Ok(source) => source,
154        Err(_) => return false,
155    };
156
157    hasher::symbol_content_hash(&source, sym.byte_start, sym.byte_end)
158        .map(|hash| hash == sym.content_hash)
159        .unwrap_or(false)
160}
161
162fn normalize_file_path(root: &Path, path: &Path) -> String {
163    let abs = if path.is_absolute() {
164        path.to_path_buf()
165    } else {
166        root.join(path)
167    };
168
169    abs.canonicalize()
170        .ok()
171        .and_then(|canonical| {
172            root.canonicalize().ok().and_then(|canonical_root| {
173                canonical
174                    .strip_prefix(canonical_root)
175                    .ok()
176                    .map(Path::to_path_buf)
177            })
178        })
179        .unwrap_or_else(|| path.to_path_buf())
180        .to_string_lossy()
181        .to_string()
182}
183
184struct FreshnessGuard;
185
186impl FreshnessGuard {
187    fn enter() -> Self {
188        // SAFETY: gcode runs freshness indexing synchronously in this CLI process
189        // and restores the variable before returning to command dispatch.
190        unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
191        Self
192    }
193}
194
195impl Drop for FreshnessGuard {
196    fn drop(&mut self) {
197        // SAFETY: see FreshnessGuard::enter.
198        unsafe { std::env::remove_var(INFLIGHT_ENV) };
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::models::CODE_INDEX_UUID_NAMESPACE;
206    use postgres::Client;
207
208    fn context_for(root: &Path) -> Context {
209        Context {
210            database_url: "postgresql://localhost/gobby-test".to_string(),
211            project_root: root.to_path_buf(),
212            project_id: "proj".to_string(),
213            quiet: true,
214            falkordb: None,
215            qdrant: None,
216            embedding: None,
217            code_vectors: crate::config::CodeVectorSettings::default(),
218            indexing: gobby_core::config::IndexingConfig::default(),
219            daemon_url: None,
220            index_scope: crate::config::ProjectIndexScope::Single,
221        }
222    }
223
224    fn symbol_hash(source: &[u8], start: usize, end: usize) -> String {
225        hasher::symbol_content_hash(source, start, end).expect("symbol hash")
226    }
227
228    fn postgres_test_context(project_id: &str) -> Context {
229        let database_url = crate::test_env::postgres_test_database_url("freshness tests");
230        db::connect_readwrite(&database_url).expect("connect freshness PostgreSQL test database");
231        Context {
232            database_url,
233            project_root: std::path::PathBuf::from("/tmp/gcode-freshness-lock-test"),
234            project_id: project_id.to_string(),
235            quiet: true,
236            falkordb: None,
237            qdrant: None,
238            embedding: None,
239            code_vectors: crate::config::CodeVectorSettings::default(),
240            indexing: gobby_core::config::IndexingConfig::default(),
241            daemon_url: None,
242            index_scope: crate::config::ProjectIndexScope::Single,
243        }
244    }
245
246    fn postgres_context_with_root(project_id: &str, root: &Path) -> Context {
247        let database_url = crate::test_env::postgres_test_database_url("freshness tests");
248        db::connect_readwrite(&database_url).expect("connect freshness PostgreSQL test database");
249        Context {
250            database_url,
251            project_root: root.to_path_buf(),
252            project_id: project_id.to_string(),
253            quiet: true,
254            falkordb: None,
255            qdrant: None,
256            embedding: None,
257            code_vectors: crate::config::CodeVectorSettings::default(),
258            indexing: gobby_core::config::IndexingConfig::default(),
259            daemon_url: None,
260            index_scope: crate::config::ProjectIndexScope::Single,
261        }
262    }
263
264    fn hold_project_lock(ctx: &Context) -> Client {
265        let mut conn =
266            db::connect_readwrite(&ctx.database_url).expect("connect test PostgreSQL hub");
267        let key = crate::index_lock::project_lock_key(&ctx.project_id);
268        conn.execute("SELECT pg_advisory_lock($1)", &[&key])
269            .expect("hold project advisory lock");
270        conn
271    }
272
273    fn set_mtime(path: &Path, time: SystemTime) {
274        std::fs::File::options()
275            .read(true)
276            .write(true)
277            .open(path)
278            .expect("open file to set mtime")
279            .set_modified(time)
280            .expect("set mtime");
281    }
282
283    fn invalidate_test_project(ctx: &Context) {
284        let mut conn =
285            db::connect_readwrite(&ctx.database_url).expect("connect test PostgreSQL hub");
286        crate::index::indexer::invalidate(&mut conn, &ctx.project_id, None)
287            .expect("invalidate test project index");
288    }
289
290    fn full_index(ctx: &Context) {
291        api::index_files(
292            api::IndexRequest {
293                project_root: ctx.project_root.clone(),
294                path_filter: None,
295                explicit_files: Vec::new(),
296                full: true,
297                require_cpp_semantics: false,
298                sync_projections: false,
299            },
300            ctx,
301        )
302        .expect("full index of test project");
303    }
304
305    mod serial_db {
306        use super::*;
307
308        #[test]
309        #[serial_test::serial(serial_db)]
310        fn no_freshness_env_short_circuits_project_refresh() {
311            let tmp = tempfile::tempdir().expect("tempdir");
312            let ctx = context_for(tmp.path());
313            unsafe { std::env::set_var(INFLIGHT_ENV, "1") };
314            let result = ensure_fresh(&ctx, FreshnessScope::Project);
315            unsafe { std::env::remove_var(INFLIGHT_ENV) };
316
317            assert_eq!(result.expect("freshness status"), FreshnessStatus::Checked);
318        }
319
320        #[test]
321        #[cfg_attr(
322            not(gcode_postgres_tests),
323            ignore = "requires a PostgreSQL test database URL"
324        )]
325        #[serial_test::serial(serial_db)]
326        fn busy_project_lock_skips_freshness_refresh() {
327            let ctx = postgres_test_context("gcode-freshness-busy");
328            let _holder = hold_project_lock(&ctx);
329
330            let status = ensure_fresh(&ctx, FreshnessScope::Project).expect("freshness status");
331
332            assert_eq!(status, FreshnessStatus::SkippedBusy);
333        }
334
335        #[test]
336        #[cfg_attr(
337            not(gcode_postgres_tests),
338            ignore = "requires a PostgreSQL test database URL"
339        )]
340        #[serial_test::serial(serial_db)]
341        fn pre_gate_skips_lock_when_unchanged_and_trips_after_a_change() {
342            let tmp = tempfile::tempdir().expect("tempdir");
343            let root = tmp.path();
344            std::fs::create_dir_all(root.join("src")).expect("create src");
345            let lib = root.join("src/lib.rs");
346            std::fs::write(&lib, b"fn main() {}\n").expect("write lib.rs");
347            std::fs::write(root.join("README.md"), b"# Title\n").expect("write README");
348
349            // Age the files well past the skew margin so a clean index leaves them
350            // unambiguously older than last_indexed_at, regardless of host/hub skew.
351            let aged = SystemTime::now() - std::time::Duration::from_secs(3600);
352            set_mtime(&lib, aged);
353            set_mtime(&root.join("README.md"), aged);
354
355            let ctx = postgres_context_with_root("gcode-freshness-pregate", root);
356
357            // Start clean, then index so code_indexed_projects.last_indexed_at = NOW().
358            invalidate_test_project(&ctx);
359            full_index(&ctx);
360
361            // Nothing changed: the pre-gate must skip the advisory lock entirely,
362            // even while another connection holds it, and report Checked. Without
363            // the gate this would force SkippedBusy.
364            let holder = hold_project_lock(&ctx);
365            let status = ensure_fresh(&ctx, FreshnessScope::Project).expect("freshness status");
366            assert_eq!(status, FreshnessStatus::Checked);
367
368            // Touch a tracked file with a future mtime so the gate trips regardless
369            // of skew; with the lock still held it reports SkippedBusy, proving it
370            // took the lock path rather than skipping.
371            set_mtime(
372                &lib,
373                SystemTime::now() + std::time::Duration::from_secs(3600),
374            );
375            let status = ensure_fresh(&ctx, FreshnessScope::Project).expect("freshness status");
376            assert_eq!(status, FreshnessStatus::SkippedBusy);
377            drop(holder);
378
379            invalidate_test_project(&ctx);
380        }
381
382        #[test]
383        #[serial_test::serial(serial_db)]
384        fn symbol_slice_check_uses_stored_byte_range_hash() {
385            let tmp = tempfile::tempdir().expect("tempdir");
386            let source = b"fn before() {}\nfn target() {}\n";
387            std::fs::write(tmp.path().join("lib.rs"), source).expect("write file");
388            let ctx = context_for(tmp.path());
389            let start = 15;
390            let end = source.len();
391            let sym = Symbol {
392                id: uuid::Uuid::new_v5(&CODE_INDEX_UUID_NAMESPACE, b"sym").to_string(),
393                project_id: "proj".to_string(),
394                file_path: "lib.rs".to_string(),
395                name: "target".to_string(),
396                qualified_name: "target".to_string(),
397                kind: "function".to_string(),
398                language: "rust".to_string(),
399                byte_start: start,
400                byte_end: end,
401                line_start: 2,
402                line_end: 2,
403                signature: None,
404                docstring: None,
405                parent_symbol_id: None,
406                content_hash: symbol_hash(source, start, end),
407                summary: None,
408                created_at: String::new(),
409                updated_at: String::new(),
410            };
411
412            assert!(symbol_slice_is_current(&ctx, &sym));
413
414            std::fs::write(
415                tmp.path().join("lib.rs"),
416                b"// shifted\nfn before() {}\nfn target() {}\n",
417            )
418            .expect("shift file");
419            assert!(!symbol_slice_is_current(&ctx, &sym));
420        }
421    }
422}