@@ -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}
0 commit comments