Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/target
.infigraph/
.git/
node_modules/
npm/bin/
npm/platforms/*/bin/
docker/**/repo
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ Automatically reindex on PR merge via GitHub webhook push events. See **[docs/WE

---

## Index Freshness

Graph-backed MCP tools (`trace_callers`, `trace_callees`, `transitive_impact`, `find_all_references`) warn you when the graph may not match your current code — after a branch switch, rebase, uncommitted edit, or a watcher restart that missed changes. See **[docs/INDEX-FRESHNESS.md](docs/INDEX-FRESHNESS.md)**.

---

## Installation

### System Requirements
Expand Down
170 changes: 170 additions & 0 deletions crates/infigraph-core/src/freshness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//! Tracks the git commit a project's graph was last built from, so callers can
//! detect when the graph may no longer match the working tree (branch switch,
//! rebase, uncommitted edits, or a watcher that missed changes while down).

use std::path::Path;

use anyhow::Result;
use serde::{Deserialize, Serialize};

const META_FILE: &str = "index_meta.json";

#[derive(Debug, Clone, Serialize, Deserialize)]
struct IndexMeta {
indexed_head: Option<String>,
indexed_at: String,
}

fn meta_path(root: &Path) -> std::path::PathBuf {
root.join(".infigraph").join(META_FILE)
}

fn git_rev_parse_head(root: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn git_is_dirty(root: &Path) -> bool {
// `.infigraph/` itself is Infigraph's own bookkeeping (this file included).
// On a project that hasn't gitignored it yet, its mere presence would
// otherwise make every freshness check report "dirty" forever.
std::process::Command::new("git")
.args(["status", "--porcelain", "--", ".", ":!.infigraph"])
.current_dir(root)
.output()
.map(|o| o.status.success() && !o.stdout.is_empty())
.unwrap_or(false)
}

/// Stamp the current git HEAD as the commit this project's graph was just
/// built from. Called after a successful `Infigraph::index()`/`index_files()`.
/// No-ops (does not error) if `root` isn't a git repository.
pub fn write_index_meta(root: &Path) -> Result<()> {
let meta = IndexMeta {
indexed_head: git_rev_parse_head(root),
indexed_at: chrono_now_rfc3339(),
};
let path = meta_path(root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(&meta)?)?;
Ok(())
}

fn chrono_now_rfc3339() -> String {
// Avoid pulling in a datetime crate for a single timestamp field: seconds
// since epoch is sufficient for "how stale is this" comparisons.
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
secs.to_string()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FreshnessStatus {
/// Indexed HEAD matches current HEAD, tree is clean, no pending reindex.
Fresh,
/// Indexed HEAD differs from current HEAD, or the working tree is dirty.
Stale,
/// HEAD matches, but a watcher has files queued for reindex.
Updating,
/// Not a git repo, or the graph has no recorded indexed HEAD yet.
Unknown,
}

impl std::fmt::Display for FreshnessStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
FreshnessStatus::Fresh => "fresh",
FreshnessStatus::Stale => "stale",
FreshnessStatus::Updating => "updating",
FreshnessStatus::Unknown => "unknown",
};
write!(f, "{s}")
}
}

#[derive(Debug, Clone)]
pub struct Freshness {
pub status: FreshnessStatus,
pub indexed_head: Option<String>,
pub current_head: Option<String>,
pub working_tree_dirty: bool,
pub pending_changes: usize,
}

impl Freshness {
/// A one-line warning suitable for prepending to a tool response.
/// Returns `None` when status is `Fresh` (nothing to warn about) or
/// `Unknown` (not a git repo, or never indexed with freshness tracking —
/// nothing wrong has actually been detected, so there's nothing to warn).
pub fn warning_line(&self) -> Option<String> {
if matches!(
self.status,
FreshnessStatus::Fresh | FreshnessStatus::Unknown
) {
return None;
}
let indexed = self.indexed_head.as_deref().unwrap_or("unknown");
let current = self.current_head.as_deref().unwrap_or("unknown");
let mut reasons = Vec::new();
if self.indexed_head != self.current_head {
reasons.push("branch/commit changed".to_string());
}
if self.working_tree_dirty {
reasons.push("uncommitted changes".to_string());
}
if self.pending_changes > 0 {
reasons.push(format!("{} file(s) pending reindex", self.pending_changes));
}
let reason = if reasons.is_empty() {
self.status.to_string()
} else {
reasons.join(", ")
};
Some(format!(
"⚠ {status}: indexed_head={indexed} current_head={current} ({reason}) — run index_project to refresh\n\n",
status = self.status
))
}
}

/// Compute freshness for `root` by comparing the recorded indexed HEAD against
/// the live git state. `pending_changes` should be the number of files a
/// running watcher has queued for reindex (0 if no watcher is tracked).
pub fn compute_freshness(root: &Path, pending_changes: usize) -> Freshness {
let current_head = git_rev_parse_head(root);
let working_tree_dirty = current_head.is_some() && git_is_dirty(root);

let indexed_head = std::fs::read_to_string(meta_path(root))
.ok()
.and_then(|s| serde_json::from_str::<IndexMeta>(&s).ok())
.and_then(|m| m.indexed_head);

let status = if current_head.is_none() || indexed_head.is_none() {
FreshnessStatus::Unknown
} else if indexed_head != current_head || working_tree_dirty {
FreshnessStatus::Stale
} else if pending_changes > 0 {
FreshnessStatus::Updating
} else {
FreshnessStatus::Fresh
};

Freshness {
status,
indexed_head,
current_head,
working_tree_dirty,
pending_changes,
}
}
5 changes: 5 additions & 0 deletions crates/infigraph-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod diff;
pub mod embed;
pub mod export;
pub mod extract;
pub mod freshness;
pub mod graph;
pub mod lang;
pub mod learned;
Expand Down Expand Up @@ -306,6 +307,8 @@ impl Infigraph {
);
}

let _ = freshness::write_index_meta(&self.root);

Ok(IndexResult {
total_files: total,
indexed_files: indexed,
Expand Down Expand Up @@ -457,6 +460,8 @@ impl Infigraph {
}
});

let _ = freshness::write_index_meta(&self.root);

Ok(IndexResult {
total_files: paths.len(),
indexed_files: indexed,
Expand Down
45 changes: 45 additions & 0 deletions crates/infigraph-core/src/watch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ where

let (mut watcher, mut rx) = create_watcher(root, ignore_dirs)?;

// Reconcile once at startup: a process restart (crash, redeploy, laptop
// sleep) has no memory of what happened to the working tree while this
// watcher wasn't running. `notify` only reports events going forward, so
// without this check a branch switch or commit made during the gap is
// silently missed until something else touches those files again.
reconcile_on_start(root, &on_event);

loop {
if stop_rx.try_recv().is_ok() {
break;
Expand Down Expand Up @@ -417,6 +424,44 @@ where
})
}

/// Compare the graph's recorded indexed HEAD against live git state when a
/// watcher starts. `notify` only reports filesystem events going forward, so
/// a process restart (crash, redeploy, sleep/wake) has no way to learn about
/// commits or branch switches that happened while it was down. If HEAD moved,
/// emit one `Modified` event per file the two commits differ on so the
/// existing pending-reindex path picks them up immediately, instead of
/// waiting for a future unrelated file write to bring things back in sync.
fn reconcile_on_start(root: &Path, on_event: &impl Fn(WatchEvent)) {
let fresh = crate::freshness::compute_freshness(root, 0);
let (Some(indexed), Some(current)) = (&fresh.indexed_head, &fresh.current_head) else {
return;
};
if indexed == current {
return;
}

let output = std::process::Command::new("git")
.args(["diff", "--name-only", indexed, current])
.current_dir(root)
.output();
let Ok(output) = output else { return };
if !output.status.success() {
return;
}

for rel in String::from_utf8_lossy(&output.stdout).lines() {
if rel.is_empty() {
continue;
}
eprintln!("[watch] reconcile: {rel} changed while watcher was not running (indexed_head={indexed} current_head={current})");
on_event(WatchEvent {
kind: WatchEventKind::Modified,
path: root.join(rel),
has_cross_file_calls: true,
});
}
}

/// Open a short-lived Infigraph instance for batch work.
fn open_transient<MR>(root: &Path, make_registry: &MR) -> Result<Infigraph>
where
Expand Down
109 changes: 109 additions & 0 deletions crates/infigraph-core/tests/freshness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
use infigraph_core::freshness::{compute_freshness, write_index_meta, FreshnessStatus};
use std::process::Command;
use tempfile::TempDir;

fn git(dir: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("git command failed to run");
assert!(status.success(), "git {:?} failed", args);
}

fn init_repo() -> TempDir {
let dir = TempDir::new().unwrap();
git(dir.path(), &["init", "-q"]);
git(dir.path(), &["config", "user.email", "test@example.com"]);
git(dir.path(), &["config", "user.name", "Test"]);
std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-q", "-m", "initial"]);
dir
}

#[test]
fn unknown_when_not_a_git_repo() {
let dir = TempDir::new().unwrap();
let fresh = compute_freshness(dir.path(), 0);
assert_eq!(fresh.status, FreshnessStatus::Unknown);
// Unknown means "can't tell", not "known stale" — a non-git project is a
// supported use case and shouldn't get a permanent bogus warning.
assert!(fresh.warning_line().is_none());
}

#[test]
fn unknown_when_never_indexed() {
let dir = init_repo();
// No index_meta.json written yet.
let fresh = compute_freshness(dir.path(), 0);
assert_eq!(fresh.status, FreshnessStatus::Unknown);
assert!(fresh.warning_line().is_none());
}

#[test]
fn fresh_when_head_matches_and_clean() {
let dir = init_repo();
write_index_meta(dir.path()).unwrap();
let fresh = compute_freshness(dir.path(), 0);
assert_eq!(fresh.status, FreshnessStatus::Fresh);
assert!(fresh.warning_line().is_none());
}

#[test]
fn stale_after_new_commit() {
let dir = init_repo();
write_index_meta(dir.path()).unwrap();

std::fs::write(dir.path().join("b.txt"), "world").unwrap();
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-q", "-m", "second"]);

let fresh = compute_freshness(dir.path(), 0);
assert_eq!(fresh.status, FreshnessStatus::Stale);
let warning = fresh.warning_line().unwrap();
assert!(warning.contains("stale"));
assert!(warning.contains("branch/commit changed"));
}

#[test]
fn stale_after_branch_switch() {
let dir = init_repo();
write_index_meta(dir.path()).unwrap();

git(dir.path(), &["checkout", "-q", "-b", "other"]);
std::fs::write(dir.path().join("c.txt"), "branch").unwrap();
git(dir.path(), &["add", "."]);
git(dir.path(), &["commit", "-q", "-m", "on other branch"]);

let fresh = compute_freshness(dir.path(), 0);
assert_eq!(fresh.status, FreshnessStatus::Stale);
assert_ne!(fresh.indexed_head, fresh.current_head);
}

#[test]
fn stale_when_working_tree_dirty() {
let dir = init_repo();
write_index_meta(dir.path()).unwrap();

// HEAD unchanged, but a tracked file has uncommitted edits.
std::fs::write(dir.path().join("a.txt"), "modified, uncommitted").unwrap();

let fresh = compute_freshness(dir.path(), 0);
assert_eq!(fresh.status, FreshnessStatus::Stale);
assert!(fresh.working_tree_dirty);
assert_eq!(fresh.indexed_head, fresh.current_head);
let warning = fresh.warning_line().unwrap();
assert!(warning.contains("uncommitted changes"));
}

#[test]
fn updating_when_pending_changes_but_head_matches() {
let dir = init_repo();
write_index_meta(dir.path()).unwrap();

let fresh = compute_freshness(dir.path(), 3);
assert_eq!(fresh.status, FreshnessStatus::Updating);
let warning = fresh.warning_line().unwrap();
assert!(warning.contains("3 file(s) pending reindex"));
}
Loading