Skip to content

Commit 9e59e6a

Browse files
userFRMclaude
andauthored
feat: auto-sync on workdir changes, not just git HEAD (#83)
Previously, auto_sync_if_stale only triggered on git HEAD changes (commits, merges, rebases). Uncommitted/staged/unstaged edits showed a passive "[stale]" warning and required manual update_rpg calls — meaning the LLM was editing code against a graph that didn't reflect its own mid-session edits. Now: auto-sync fires on any workdir change (same trigger as detect_workdir_changes already used for the stale warning). A changeset hash of file paths + (size, mtime) prevents redundant re-parsing when the dirty set hasn't changed — 50 queries after 1 edit trigger exactly 1 sync, not 50. Design decision: - All-workdir (not staged-only) because the primary use case is the mid-edit query: "I just changed validate_token, what depends on it?" — the agent hasn't staged yet, it's iterating. Staged-only would silently give answers based on the pre-edit function. - Changeset hash uses (size, mtime) stat, which captures re-saves of the same file (different mtime → different hash → re-sync). - update_rpg clears the changeset marker after manual sync so the next auto-sync re-evaluates cleanly. - On update failure, markers are still set to prevent retry loops. Removed staleness_notice_unstaged helper (no longer needed — the new auto-sync handles the workdir case directly). Added 3 unit tests covering hash stability, path discrimination, and mtime/size sensitivity. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e03851f commit 9e59e6a

2 files changed

Lines changed: 160 additions & 20 deletions

File tree

crates/rpg-mcp/src/server.rs

Lines changed: 156 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ pub(crate) struct RpgServer {
5353
pub(crate) prompt_versions: PromptVersions,
5454
/// Last git HEAD SHA at which auto-sync ran. Prevents redundant updates.
5555
pub(crate) last_auto_sync_head: Arc<RwLock<Option<String>>>,
56+
/// Hash of the last-synced workdir changeset (dirty files + their stat).
57+
/// Combined with `last_auto_sync_head` to detect when a re-sync is needed
58+
/// for uncommitted/staged/unstaged changes.
59+
pub(crate) last_auto_sync_changeset: Arc<RwLock<Option<String>>>,
5660
/// Guard: true while auto_lift is running. Rejects concurrent lift calls.
5761
pub(crate) lift_in_progress: Arc<std::sync::atomic::AtomicBool>,
5862
}
@@ -90,6 +94,7 @@ impl RpgServer {
9094
tool_router: Self::create_tool_router(),
9195
prompt_versions: PromptVersions::new(),
9296
last_auto_sync_head: Arc::new(RwLock::new(initial_head)),
97+
last_auto_sync_changeset: Arc::new(RwLock::new(None)),
9398
lift_in_progress: Arc::new(std::sync::atomic::AtomicBool::new(false)),
9499
}
95100
}
@@ -123,30 +128,70 @@ impl RpgServer {
123128

124129
/// Auto-sync the graph if stale, returning a notice string.
125130
///
126-
/// Runs a structural-only update (no re-lifting) when git HEAD has moved
127-
/// since the last sync. Uses `last_auto_sync_head` to avoid redundant work.
128-
/// Falls back to a passive staleness notice on error.
131+
/// Syncs on two triggers:
132+
/// 1. **HEAD changed** — commits, merges, rebases.
133+
/// 2. **Workdir changed** — staged or unstaged file edits since last sync.
134+
///
135+
/// Uses `last_auto_sync_head` + `last_auto_sync_changeset` to avoid redundant
136+
/// re-parses. The changeset hash includes file paths and `(mtime, size)` stat
137+
/// so repeated saves of the same file trigger re-sync, but idle queries don't.
138+
///
139+
/// Structural-only update (no re-lifting). Falls back to a passive staleness
140+
/// notice on error.
129141
pub(crate) async fn auto_sync_if_stale(&self) -> String {
130-
// Fast path: compare HEAD SHA to last known sync point
142+
// Step 1: Get current HEAD (cheap, just opens .git/HEAD)
131143
let Ok(current_head) = rpg_encoder::evolution::get_head_sha(&self.project_root) else {
132144
return self.staleness_notice().await;
133145
};
134146

147+
// Step 2: Detect current workdir state (changes + stat hash) under read lock
148+
let (source_changes, current_changeset) = {
149+
let guard = self.graph.read().await;
150+
let Some(graph) = guard.as_ref() else {
151+
return String::new();
152+
};
153+
let Ok(changes) =
154+
rpg_encoder::evolution::detect_workdir_changes(&self.project_root, graph)
155+
else {
156+
return String::new();
157+
};
158+
let changes =
159+
rpg_encoder::evolution::filter_rpgignore_changes(&self.project_root, changes);
160+
let languages = Self::resolve_languages(&graph.metadata);
161+
let source_changes = if languages.is_empty() {
162+
changes
163+
} else {
164+
rpg_encoder::evolution::filter_source_changes(changes, &languages)
165+
};
166+
let hash = Self::compute_changeset_hash(&source_changes, &self.project_root);
167+
(source_changes, hash)
168+
};
169+
170+
// Step 3: Check if (HEAD, changeset) matches last-synced state
135171
{
136-
let last = self.last_auto_sync_head.read().await;
137-
if last.as_deref() == Some(current_head.as_str()) {
138-
// HEAD unchanged since last sync — check for unstaged changes only
139-
return self.staleness_notice_unstaged().await;
172+
let last_head = self.last_auto_sync_head.read().await;
173+
let last_changeset = self.last_auto_sync_changeset.read().await;
174+
if last_head.as_deref() == Some(current_head.as_str())
175+
&& last_changeset.as_deref() == Some(current_changeset.as_str())
176+
{
177+
return String::new(); // already synced this exact state
140178
}
141179
}
142180

143-
// HEAD moved — try structural auto-update
181+
// Step 4: If nothing actually changed, just update markers (HEAD moved but no source diff)
182+
if source_changes.is_empty() {
183+
*self.last_auto_sync_head.write().await = Some(current_head);
184+
*self.last_auto_sync_changeset.write().await = Some(current_changeset);
185+
return String::new();
186+
}
187+
188+
// Step 5: Real changes exist — acquire write lock and run update
144189
let mut guard = self.graph.write().await;
145190
let Some(graph) = guard.as_mut() else {
146191
return String::new();
147192
};
148193

149-
// Detect paradigms for framework-aware classification
194+
// Paradigm setup for framework-aware classification
150195
let detected_langs = Self::resolve_languages(&graph.metadata);
151196
let paradigm_defs = rpg_parser::paradigms::defs::load_builtin_defs().unwrap_or_default();
152197
let qcache_result =
@@ -171,6 +216,7 @@ impl RpgServer {
171216
graph.metadata.paradigms = paradigm_names;
172217
let _ = storage::save(&self.project_root, graph);
173218
*self.last_auto_sync_head.write().await = Some(current_head);
219+
*self.last_auto_sync_changeset.write().await = Some(current_changeset);
174220

175221
if summary.entities_added == 0
176222
&& summary.entities_modified == 0
@@ -188,27 +234,69 @@ impl RpgServer {
188234
summary.entities_added, summary.entities_removed, summary.entities_modified,
189235
);
190236
if needs_lifting > 0 || needs_relift > 0 {
191-
notice.push_str(&format!("; {} need lifting", needs_lifting + needs_relift,));
237+
notice.push_str(&format!("; {} need lifting", needs_lifting + needs_relift));
192238
}
193239
notice.push_str("]\n\n");
194240
notice
195241
}
196242
Err(e) => {
197243
eprintln!("rpg: auto-sync failed (non-fatal): {e}");
198-
// Update HEAD anyway to avoid retrying a failing update every call
244+
// Update markers anyway so we don't retry a failing update every call
199245
*self.last_auto_sync_head.write().await = Some(current_head);
200-
// MUST drop the write lock before calling staleness_notice (which reads)
246+
*self.last_auto_sync_changeset.write().await = Some(current_changeset);
247+
// Drop write lock before calling staleness_notice (which reads)
201248
drop(guard);
202249
self.staleness_notice().await
203250
}
204251
}
205252
}
206253

207-
/// Lightweight staleness check for unstaged/uncommitted changes only.
208-
/// Used when HEAD hasn't moved (auto-sync already ran for this HEAD).
209-
/// Delegates to `staleness_notice` which handles the full detection.
210-
async fn staleness_notice_unstaged(&self) -> String {
211-
self.staleness_notice().await
254+
/// Compute a stable hash of the current workdir changeset.
255+
///
256+
/// Includes the path, change type, and `(size, mtime)` stat for each
257+
/// added/modified/renamed file. Deleted files hash their path only.
258+
/// Same changeset + same stat = same hash = no re-sync. Second save of the
259+
/// same file changes mtime → different hash → re-sync fires.
260+
fn compute_changeset_hash(
261+
changes: &[rpg_encoder::evolution::FileChange],
262+
project_root: &std::path::Path,
263+
) -> String {
264+
use rpg_encoder::evolution::FileChange;
265+
use sha2::{Digest, Sha256};
266+
267+
let mut hasher = Sha256::new();
268+
for change in changes {
269+
match change {
270+
FileChange::Added(p) | FileChange::Modified(p) => {
271+
hasher.update(format!("{:?}", change).as_bytes());
272+
if let Ok(meta) = std::fs::metadata(project_root.join(p)) {
273+
hasher.update(meta.len().to_le_bytes());
274+
if let Ok(modified) = meta.modified()
275+
&& let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH)
276+
{
277+
hasher.update(duration.as_nanos().to_le_bytes());
278+
}
279+
}
280+
}
281+
FileChange::Deleted(p) => {
282+
hasher.update(format!("deleted:{}", p.display()).as_bytes());
283+
}
284+
FileChange::Renamed { from, to } => {
285+
hasher
286+
.update(format!("renamed:{}->{}", from.display(), to.display()).as_bytes());
287+
if let Ok(meta) = std::fs::metadata(project_root.join(to)) {
288+
hasher.update(meta.len().to_le_bytes());
289+
if let Ok(modified) = meta.modified()
290+
&& let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH)
291+
{
292+
hasher.update(duration.as_nanos().to_le_bytes());
293+
}
294+
}
295+
}
296+
}
297+
}
298+
let result = hasher.finalize();
299+
format!("{:x}", result)[..16].to_string()
212300
}
213301

214302
/// Resolve all indexed languages from graph metadata (multi-language support).
@@ -560,4 +648,54 @@ mod tests {
560648
let hash2 = PromptVersions::hash_prompt("content B");
561649
assert_ne!(hash1, hash2);
562650
}
651+
652+
#[test]
653+
fn test_changeset_hash_empty_is_stable() {
654+
let tmp = tempfile::tempdir().unwrap();
655+
let h1 = RpgServer::compute_changeset_hash(&[], tmp.path());
656+
let h2 = RpgServer::compute_changeset_hash(&[], tmp.path());
657+
assert_eq!(h1, h2, "empty changeset hash must be deterministic");
658+
}
659+
660+
#[test]
661+
fn test_changeset_hash_differs_by_path() {
662+
use rpg_encoder::evolution::FileChange;
663+
let tmp = tempfile::tempdir().unwrap();
664+
let h_deleted_a =
665+
RpgServer::compute_changeset_hash(&[FileChange::Deleted("a.rs".into())], tmp.path());
666+
let h_deleted_b =
667+
RpgServer::compute_changeset_hash(&[FileChange::Deleted("b.rs".into())], tmp.path());
668+
assert_ne!(
669+
h_deleted_a, h_deleted_b,
670+
"different paths → different hashes"
671+
);
672+
}
673+
674+
#[test]
675+
fn test_changeset_hash_reflects_mtime() {
676+
use rpg_encoder::evolution::FileChange;
677+
use std::io::Write;
678+
let tmp = tempfile::tempdir().unwrap();
679+
let file_path = tmp.path().join("x.rs");
680+
std::fs::File::create(&file_path)
681+
.unwrap()
682+
.write_all(b"v1")
683+
.unwrap();
684+
685+
let change = FileChange::Modified("x.rs".into());
686+
let h1 = RpgServer::compute_changeset_hash(std::slice::from_ref(&change), tmp.path());
687+
688+
// Different content + guaranteed-later mtime by sleeping a moment
689+
std::thread::sleep(std::time::Duration::from_millis(20));
690+
std::fs::File::create(&file_path)
691+
.unwrap()
692+
.write_all(b"v2_different_length")
693+
.unwrap();
694+
695+
let h2 = RpgServer::compute_changeset_hash(std::slice::from_ref(&change), tmp.path());
696+
assert_ne!(
697+
h1, h2,
698+
"same path + different size/mtime must yield different hashes"
699+
);
700+
}
563701
}

crates/rpg-mcp/src/tools.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -827,9 +827,10 @@ impl RpgServer {
827827
*self.lifting_session.write().await = None;
828828
*self.hierarchy_session.write().await = None;
829829

830-
// Update auto-sync HEAD
830+
// Update auto-sync markers — force re-evaluation on next query
831831
*self.last_auto_sync_head.write().await =
832832
rpg_encoder::evolution::get_head_sha(&self.project_root).ok();
833+
*self.last_auto_sync_changeset.write().await = None;
833834

834835
let mut out = format!(
835836
"Lifting complete ({}, {}).\n\
@@ -1790,9 +1791,10 @@ impl RpgServer {
17901791

17911792
storage::save(&self.project_root, g).map_err(|e| format!("Failed to save RPG: {}", e))?;
17921793

1793-
// Update auto-sync HEAD so next query doesn't redundantly re-sync
1794+
// Update auto-sync markers — force re-evaluation on next query
17941795
*self.last_auto_sync_head.write().await =
17951796
rpg_encoder::evolution::get_head_sha(&self.project_root).ok();
1797+
*self.last_auto_sync_changeset.write().await = None;
17961798

17971799
// Clear sessions — entity list changed
17981800
*self.lifting_session.write().await = None;

0 commit comments

Comments
 (0)