Skip to content

Commit e1423ad

Browse files
userFRMclaude
andauthored
fix: v0.8.0 workdir auto-sync was a no-op (Codex audit catch) (#84)
PR #83 in v0.8.0 added workdir change detection and a changeset hash, but the underlying run_update only diffed base_commit..HEAD via detect_changes. So when HEAD hadn't moved, run_update returned "no changes" even though the worktree was dirty — and we then cached that no-op as "synced", silently serving stale graph data. Codex caught this via reproduction: build a temp repo, edit a file without committing, run update, see "RPG is up to date." Fixed and re-verified end-to-end: $ rpg-encoder update Entities modified: 2 Entities removed: 1 Edges added: 4 $ rpg-encoder search "new_function" 1. new_function_added_without_commit [src/lib.rs:3] Changes: - New public APIs in rpg-encoder::evolution: - run_update_workdir — applies committed + staged + unstaged diff - run_update_from_changes — applies caller-supplied FileChange list - MCP auto_sync_if_stale rebuilt to use these: - Uses workdir diff, not committed-only diff - Tracks last_auto_sync_workdir_paths for revert detection (file goes from dirty back to HEAD → re-parse to restore HEAD content) - On error, does NOT cache markers (silent staleness > retry cost) - update_rpg MCP tool defaults to workdir-aware sync; --since for committed-only diff - CLI `rpg-encoder update` matches: workdir-aware by default - README "Six crates" → "Seven crates"; tools.rs "17 tools" → "27 tools" Bumps to v0.8.1 across Cargo.toml, server.json, npm/package.json, Gemini extension, and CHANGELOG. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9e59e6a commit e1423ad

11 files changed

Lines changed: 184 additions & 50 deletions

File tree

.gemini/extensions/rpg/gemini-extension.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rpg-encoder",
3-
"version": "0.8.0",
3+
"version": "0.8.1",
44
"description": "Build and query semantic code graphs (Repository Planning Graphs) for AI-assisted code understanding. Provides entity search, dependency exploration, and autonomous LLM-driven semantic lifting.",
55
"mcpServers": {
66
"rpg-encoder": {

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.8.1] - 2026-04-14
9+
10+
### Fixed
11+
12+
- **CRITICAL: Auto-sync on workdir changes was a no-op** (caught by Codex audit) —
13+
v0.8.0 detected workdir changes and computed a hash, but the underlying
14+
`run_update` only diffed `base_commit..HEAD`, so uncommitted edits were
15+
silently ignored. The hash was then cached as "synced", masking the
16+
staleness from later queries. This made the headline v0.8.0 feature
17+
(workdir auto-sync) effectively non-functional.
18+
- **Auto-sync error path no longer caches markers** — transient failures
19+
no longer leave the server silently stale. The next query retries.
20+
- **Revert detection** — when a previously-dirty file returns to its HEAD
21+
state, the graph is now restored. Previously, the entity additions from
22+
the dirty version persisted forever.
23+
- **CLI `update` now defaults to workdir-aware** — matches the MCP server.
24+
Pass `--since <commit>` for commit-range diffing as before.
25+
- README said "six crates" while listing seven. Now says seven.
26+
- `tools.rs` module comment said "17 tools" — actually 27.
27+
28+
### Added
29+
30+
- **`run_update_workdir`** in `rpg-encoder::evolution` — public API that
31+
applies the working-tree diff (committed + staged + unstaged) instead of
32+
the committed-only diff.
33+
- **`run_update_from_changes`** in `rpg-encoder::evolution` — public API
34+
that applies a caller-supplied `Vec<FileChange>`. Used by the MCP server
35+
to compose workdir changes with revert-detection re-parses.
36+
837
## [0.8.0] - 2026-04-13
938

1039
### Added

Cargo.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ members = [
1111
]
1212

1313
[workspace.package]
14-
version = "0.8.0"
14+
version = "0.8.1"
1515
edition = "2024"
1616
license = "MIT"
1717
authors = ["userFRM"]

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Instead of grepping through files, the LLM calls `semantic_snapshot` once and re
7777
<img src="diagrams/auto-staleness.webp" alt="Git HEAD moves → RPG Server auto-syncs → update_rpg applies additions/modifications/removals → graph always fresh, zero agent action" width="80%" />
7878
</p>
7979

80-
When git HEAD moves (commits, merges, rebases), the MCP server automatically runs a structural update before responding to the next query. No manual `update_rpg` calls, no stale warnings your agent ignores. The graph owns its own consistency.
80+
Whenever your working tree changes — committed, staged, or unstaged — the MCP server automatically re-syncs before responding to the next query. A changeset hash over `(path, size, mtime)` means repeated saves of the same file trigger one sync, and idle queries trigger none. Reverts are detected too: if a previously-dirty file returns to its HEAD state, the graph is restored.
8181

8282
### Two ways to lift
8383

@@ -96,7 +96,7 @@ When git HEAD moves (commits, merges, rebases), the MCP server automatically run
9696
<img src="diagrams/architecture.webp" alt="Your codebase (15 languages) → RPG Engine (5 Rust crates: parser, encoder, nav, lift, mcp) → Clients (Claude Code, Cursor, opencode) via MCP Protocol" width="95%" />
9797
</p>
9898

99-
Six Rust crates, one MCP server binary, one CLI binary:
99+
Seven Rust crates, one MCP server binary, one CLI binary:
100100

101101
| Crate | Role |
102102
|-------|------|

crates/rpg-cli/src/main.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -612,12 +612,22 @@ fn cmd_update(project_root: &Path, since: Option<String>) -> Result<()> {
612612
};
613613

614614
eprintln!("Running incremental update...");
615-
let summary = rpg_encoder::evolution::run_update(
616-
&mut graph,
617-
project_root,
618-
since.as_deref(),
619-
Some(&paradigm_pipeline),
620-
)?;
615+
// Default: workdir-aware (committed + staged + unstaged).
616+
// When --since is supplied, fall back to committed-only diff.
617+
let summary = if let Some(since) = since.as_deref() {
618+
rpg_encoder::evolution::run_update(
619+
&mut graph,
620+
project_root,
621+
Some(since),
622+
Some(&paradigm_pipeline),
623+
)?
624+
} else {
625+
rpg_encoder::evolution::run_update_workdir(
626+
&mut graph,
627+
project_root,
628+
Some(&paradigm_pipeline),
629+
)?
630+
};
621631

622632
rpg_core::storage::save_with_config(project_root, &graph, &config.storage)?;
623633

crates/rpg-encoder/src/evolution.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -914,13 +914,49 @@ pub fn route_new_entity(graph: &mut RPGraph, entity_id: &str) -> Option<String>
914914

915915
/// Run the full incremental update pipeline (structural only).
916916
///
917+
/// Sources changes from `base_commit..HEAD` (committed diff only). For
918+
/// including staged/unstaged edits, see [`run_update_workdir`].
919+
///
917920
/// Semantic re-lifting of modified entities is left to the connected
918921
/// coding agent via the MCP interactive protocol.
919922
pub fn run_update(
920923
graph: &mut RPGraph,
921924
project_root: &Path,
922925
since: Option<&str>,
923926
paradigm: Option<&ParadigmPipeline<'_>>,
927+
) -> Result<UpdateSummary> {
928+
let changes = detect_changes(project_root, graph, since)?;
929+
run_update_from_changes(graph, project_root, changes, paradigm)
930+
}
931+
932+
/// Run the full incremental update pipeline, sourcing changes from the
933+
/// working tree (committed + staged + unstaged).
934+
///
935+
/// Unlike [`run_update`], this captures uncommitted edits, making it the
936+
/// right choice for mid-development auto-sync. Sets `base_commit` to
937+
/// current HEAD after applying — the working tree delta is baked into the
938+
/// graph.
939+
pub fn run_update_workdir(
940+
graph: &mut RPGraph,
941+
project_root: &Path,
942+
paradigm: Option<&ParadigmPipeline<'_>>,
943+
) -> Result<UpdateSummary> {
944+
let changes = detect_workdir_changes(project_root, graph)?;
945+
run_update_from_changes(graph, project_root, changes, paradigm)
946+
}
947+
948+
/// Run the full incremental update pipeline against a caller-supplied
949+
/// change set.
950+
///
951+
/// Use this when you already know which files changed (e.g., auto-sync
952+
/// tracking previously-dirty files that went clean and need re-parse to
953+
/// the HEAD version). The changes are filtered by language, `.rpgignore`,
954+
/// and file-system existence before being applied.
955+
pub fn run_update_from_changes(
956+
graph: &mut RPGraph,
957+
project_root: &Path,
958+
changes: Vec<FileChange>,
959+
paradigm: Option<&ParadigmPipeline<'_>>,
924960
) -> Result<UpdateSummary> {
925961
// Resolve all indexed languages (multi-language support)
926962
let languages: Vec<Language> = if graph.metadata.languages.is_empty() {
@@ -943,7 +979,6 @@ pub fn run_update(
943979
));
944980
}
945981

946-
let changes = detect_changes(project_root, graph, since)?;
947982
let changes = filter_rpgignore_changes(project_root, changes);
948983
let mut changes = filter_source_changes(changes, &languages);
949984

crates/rpg-mcp/src/server.rs

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ pub(crate) struct RpgServer {
5757
/// Combined with `last_auto_sync_head` to detect when a re-sync is needed
5858
/// for uncommitted/staged/unstaged changes.
5959
pub(crate) last_auto_sync_changeset: Arc<RwLock<Option<String>>>,
60+
/// Paths that were dirty at the last successful auto-sync. Lets us detect
61+
/// reverts: when a previously-dirty file returns to clean, the workdir
62+
/// diff no longer lists it — we must re-parse it to restore HEAD content.
63+
pub(crate) last_auto_sync_workdir_paths:
64+
Arc<RwLock<std::collections::HashSet<std::path::PathBuf>>>,
6065
/// Guard: true while auto_lift is running. Rejects concurrent lift calls.
6166
pub(crate) lift_in_progress: Arc<std::sync::atomic::AtomicBool>,
6267
}
@@ -95,6 +100,7 @@ impl RpgServer {
95100
prompt_versions: PromptVersions::new(),
96101
last_auto_sync_head: Arc::new(RwLock::new(initial_head)),
97102
last_auto_sync_changeset: Arc::new(RwLock::new(None)),
103+
last_auto_sync_workdir_paths: Arc::new(RwLock::new(std::collections::HashSet::new())),
98104
lift_in_progress: Arc::new(std::sync::atomic::AtomicBool::new(false)),
99105
}
100106
}
@@ -128,24 +134,29 @@ impl RpgServer {
128134

129135
/// Auto-sync the graph if stale, returning a notice string.
130136
///
131-
/// Syncs on two triggers:
132-
/// 1. **HEAD changed** — commits, merges, rebases.
133-
/// 2. **Workdir changed** — staged or unstaged file edits since last sync.
137+
/// Syncs the graph to match the current **working tree** (committed + staged
138+
/// + unstaged). Triggers on:
134139
///
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.
140+
/// 1. HEAD changed (commits, merges, rebases).
141+
/// 2. Any workdir file in the relevant language set added/modified/deleted/renamed.
142+
/// 3. A previously-dirty file returning to clean state (revert detection).
138143
///
139-
/// Structural-only update (no re-lifting). Falls back to a passive staleness
140-
/// notice on error.
144+
/// Uses `last_auto_sync_head` + `last_auto_sync_changeset` to skip re-sync
145+
/// when nothing changed since the last successful run. The changeset hash
146+
/// covers path + `(size, mtime)` stat so repeated saves trigger re-sync
147+
/// but idle queries don't.
148+
///
149+
/// Structural-only update (no re-lifting). On error, does **not** cache
150+
/// markers — the next query will retry, so transient failures don't
151+
/// silently leave the server stale.
141152
pub(crate) async fn auto_sync_if_stale(&self) -> String {
142153
// Step 1: Get current HEAD (cheap, just opens .git/HEAD)
143154
let Ok(current_head) = rpg_encoder::evolution::get_head_sha(&self.project_root) else {
144155
return self.staleness_notice().await;
145156
};
146157

147-
// Step 2: Detect current workdir state (changes + stat hash) under read lock
148-
let (source_changes, current_changeset) = {
158+
// Step 2: Detect current workdir state under read lock
159+
let (source_changes, current_paths, current_changeset) = {
149160
let guard = self.graph.read().await;
150161
let Some(graph) = guard.as_ref() else {
151162
return String::new();
@@ -163,11 +174,30 @@ impl RpgServer {
163174
} else {
164175
rpg_encoder::evolution::filter_source_changes(changes, &languages)
165176
};
177+
let paths = Self::change_paths(&source_changes);
166178
let hash = Self::compute_changeset_hash(&source_changes, &self.project_root);
167-
(source_changes, hash)
179+
(source_changes, paths, hash)
180+
};
181+
182+
// Step 3: Union with previously-dirty paths (revert detection).
183+
// Any file that was dirty last time but isn't in the current workdir
184+
// diff has returned to clean state — we need to re-parse it to restore
185+
// HEAD content in the graph.
186+
let effective_changes = {
187+
let last_paths = self.last_auto_sync_workdir_paths.read().await;
188+
let mut effective = source_changes.clone();
189+
for path in last_paths.difference(&current_paths) {
190+
let abs = self.project_root.join(path);
191+
if abs.is_file() {
192+
effective.push(rpg_encoder::evolution::FileChange::Modified(path.clone()));
193+
} else {
194+
effective.push(rpg_encoder::evolution::FileChange::Deleted(path.clone()));
195+
}
196+
}
197+
effective
168198
};
169199

170-
// Step 3: Check if (HEAD, changeset) matches last-synced state
200+
// Step 4: Check if (HEAD, changeset) matches last-synced state
171201
{
172202
let last_head = self.last_auto_sync_head.read().await;
173203
let last_changeset = self.last_auto_sync_changeset.read().await;
@@ -178,14 +208,15 @@ impl RpgServer {
178208
}
179209
}
180210

181-
// Step 4: If nothing actually changed, just update markers (HEAD moved but no source diff)
182-
if source_changes.is_empty() {
211+
// Step 5: Nothing to apply — just update markers (HEAD moved with no source diff)
212+
if effective_changes.is_empty() {
183213
*self.last_auto_sync_head.write().await = Some(current_head);
184214
*self.last_auto_sync_changeset.write().await = Some(current_changeset);
215+
*self.last_auto_sync_workdir_paths.write().await = current_paths;
185216
return String::new();
186217
}
187218

188-
// Step 5: Real changes exist — acquire write lock and run update
219+
// Step 6: Acquire write lock and run update with our composed change set
189220
let mut guard = self.graph.write().await;
190221
let Some(graph) = guard.as_mut() else {
191222
return String::new();
@@ -210,13 +241,18 @@ impl RpgServer {
210241
}
211242
});
212243

213-
match rpg_encoder::evolution::run_update(graph, &self.project_root, None, pipeline.as_ref())
214-
{
244+
match rpg_encoder::evolution::run_update_from_changes(
245+
graph,
246+
&self.project_root,
247+
effective_changes,
248+
pipeline.as_ref(),
249+
) {
215250
Ok(summary) => {
216251
graph.metadata.paradigms = paradigm_names;
217252
let _ = storage::save(&self.project_root, graph);
218253
*self.last_auto_sync_head.write().await = Some(current_head);
219254
*self.last_auto_sync_changeset.write().await = Some(current_changeset);
255+
*self.last_auto_sync_workdir_paths.write().await = current_paths;
220256

221257
if summary.entities_added == 0
222258
&& summary.entities_modified == 0
@@ -241,16 +277,30 @@ impl RpgServer {
241277
}
242278
Err(e) => {
243279
eprintln!("rpg: auto-sync failed (non-fatal): {e}");
244-
// Update markers anyway so we don't retry a failing update every call
245-
*self.last_auto_sync_head.write().await = Some(current_head);
246-
*self.last_auto_sync_changeset.write().await = Some(current_changeset);
247-
// Drop write lock before calling staleness_notice (which reads)
280+
// Do NOT cache markers on error — the next call must retry.
281+
// Silent staleness is worse than repeated sync attempts.
248282
drop(guard);
249283
self.staleness_notice().await
250284
}
251285
}
252286
}
253287

288+
/// Extract the path set from a changeset (for revert detection).
289+
fn change_paths(
290+
changes: &[rpg_encoder::evolution::FileChange],
291+
) -> std::collections::HashSet<std::path::PathBuf> {
292+
use rpg_encoder::evolution::FileChange;
293+
changes
294+
.iter()
295+
.map(|c| match c {
296+
FileChange::Added(p) | FileChange::Modified(p) | FileChange::Deleted(p) => {
297+
p.clone()
298+
}
299+
FileChange::Renamed { to, .. } => to.clone(),
300+
})
301+
.collect()
302+
}
303+
254304
/// Compute a stable hash of the current workdir changeset.
255305
///
256306
/// Includes the path, change type, and `(size, mtime)` stat for each

0 commit comments

Comments
 (0)