fix: remove server-side tree-sitter parsing, require git-remote-cospan Structural analysis (scope diffs, breaking change detection, schema graphs) now comes exclusively from the panproto-vcs store populated by git-remote-cospan client-side parsing. Server-side parsing is removed from diffCommits, getProjectSchema, and getFileSchema. For repos pushed via plain git push, the UI shows a prompt to install git-remote-cospan and re-push. The old 15-second server-side parse is gone. Commit pages now load in under a second. Rationale: tree-sitter parsing must happen on the user's machine at push time. The node is a storage server, not a parser. See panproto/panproto#28 for git-remote-cospan distribution. - diff_commits.rs: new try_load_structural_diffs_from_vcs reads pre-parsed schemas from the vcs store, filters per file, runs panproto_check::diff. No tree-sitter on the server. - get_project_schema.rs: returns needsGitRemoteCospan: true when the vcs store is empty. Language stats come from file extensions. - get_file_schema.rs: returns empty response when vcs data absent. - Added DIFF_CACHE for already-computed diffs (immutable per commit OID pair, never invalidates). - SchemaHealthCard and StructuralDiff show install prompts with brew/cargo install commands when structural data unavailable.
Author: Aaron Steven White
Commit
13d2117dd9bd22e59e28d13464d0086c29d58378Parent: 91e79a02a2
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan7 files changed +209 -190
@@ -34,6 +34,9 @@ export interface ProjectSchemaResponse {
3434 parsedFileCount: number; 3535 languages: SchemaLanguage[]; 3636 fileSchemas: FileSchemaEntry[]; 37+ /** True when the repo was pushed via raw git and has no pre-parsed schemas. 38+ * The user should install git-remote-cospan to enable structural analysis. */ 39+ needsGitRemoteCospan?: boolean; 3740 } 3841 3942 export interface CommitSchemaStat {
@@ -32,6 +32,29 @@
3232 ); 3333 </script> 3434 35+{#if projectSchema?.needsGitRemoteCospan} 36+ <div class="mb-4 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4"> 37+ <div class="flex items-start gap-3"> 38+ <svg class="h-5 w-5 shrink-0 mt-0.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> 39+ <path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> 40+ </svg> 41+ <div class="flex-1"> 42+ <span class="text-sm font-medium text-blue-400">Structural analysis unavailable</span> 43+ <p class="mt-1 text-xs text-text-secondary"> 44+ This repository was pushed via plain <code class="font-mono">git push</code>. Cospan's schematic VCS features 45+ (breaking change detection, scope-level diffs, schema graphs) require client-side parsing via 46+ <code class="font-mono">git-remote-cospan</code>. 47+ </p> 48+ <p class="mt-2 text-xs text-text-secondary"> 49+ Install the helper and re-push: 50+ </p> 51+ <pre class="mt-1 rounded bg-surface-0 px-2 py-1 text-[11px] font-mono text-text-primary overflow-x-auto"><code>brew install git-remote-cospan # or: cargo install git-remote-cospan 52+git push panproto://did:plc:.../repo main</code></pre> 53+ </div> 54+ </div> 55+ </div> 56+{/if} 57+ 3558 {#if importStatus && !importStatus.ready} 3659 <div class="mb-4 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4"> 3760 <div class="flex items-center gap-3">
@@ -160,6 +160,24 @@
160160 <p class="text-sm text-text-secondary">No changes between these commits.</p> 161161 </div> 162162 {:else} 163+ {@const noStructural = diff.files.every(f => !f.binary && !getStructuralDiff(f))} 164+ {#if noStructural} 165+ <div class="mb-4 rounded-lg border border-blue-500/30 bg-blue-500/5 p-4"> 166+ <div class="flex items-start gap-3"> 167+ <svg class="h-5 w-5 shrink-0 mt-0.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> 168+ <path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> 169+ </svg> 170+ <div class="flex-1"> 171+ <span class="text-sm font-medium text-blue-400">Structural diff unavailable</span> 172+ <p class="mt-1 text-xs text-text-secondary"> 173+ These commits were pushed via plain <code class="font-mono">git push</code>, so no pre-parsed 174+ schemas are available. Install <code class="font-mono">git-remote-cospan</code> and re-push to 175+ see scope-level changes, breaking change detection, and semantic diffs. 176+ </p> 177+ </div> 178+ </div> 179+ </div> 180+ {/if} 163181 <!-- Summary bar --> 164182 <div class="mb-4 flex flex-wrap items-center gap-4 rounded-lg border border-border bg-surface-1 px-4 py-3 text-sm"> 165183 <span class="font-medium text-text-primary">
@@ -74,6 +74,7 @@ pub async fn compare_branch_schemas(
7474 let mut total_removed_edges = 0usize; 7575 let mut breaking_changes: Vec<Value> = Vec::new(); 7676 let mut non_breaking_changes: Vec<Value> = Vec::new(); 77+ let mut scope_changes: Vec<Value> = Vec::new(); 7778 let mut changed_files: Vec<String> = Vec::new(); 7879 let mut base_vertex_total = 0usize; 7980 let mut head_vertex_total = 0usize;
@@ -130,6 +131,18 @@ pub async fn compare_branch_schemas(
130131 } 131132 } 132133 } 134+ // Include scope-level changes per file (tagged with path) 135+ if let Some(sc) = sd_json["scopeChanges"].as_array() { 136+ for c in sc { 137+ if scope_changes.len() < 100 { 138+ let mut tagged = c.clone(); 139+ if let Some(obj) = tagged.as_object_mut() { 140+ obj.insert("path".to_string(), json!(path)); 141+ } 142+ scope_changes.push(tagged); 143+ } 144+ } 145+ } 133146 } 134147 } 135148
@@ -148,6 +161,7 @@ pub async fn compare_branch_schemas(
148161 "removedEdges": total_removed_edges, 149162 "breakingChanges": breaking_changes, 150163 "nonBreakingChanges": non_breaking_changes, 164+ "scopeChanges": scope_changes, 151165 "changedFiles": changed_files, 152166 "baseVertexCount": base_vertex_total, 153167 "headVertexCount": head_vertex_total,
@@ -22,7 +22,8 @@
2222 //! - `contextLines`: optional unified context lines (default 3) 2323 2424 use std::cell::RefCell; 25-use std::sync::Arc; 25+use std::collections::HashMap; 26+use std::sync::{Arc, LazyLock, Mutex}; 2627 2728 use axum::Json; 2829 use axum::extract::{Query, State};
@@ -33,6 +34,12 @@ use serde_json::json;
3334 use crate::error::NodeError; 3435 use crate::state::NodeState; 3536 37+/// Cache for diff results keyed by (did, repo, from_oid, to_oid, context_lines). 38+/// Commit diffs are immutable once computed (git OIDs are content-addressed), 39+/// so this cache never needs invalidation. 40+static DIFF_CACHE: LazyLock<Mutex<HashMap<String, DiffCommitsResult>>> = 41+ LazyLock::new(|| Mutex::new(HashMap::new())); 42+ 3643 #[derive(Deserialize)] 3744 #[serde(rename_all = "camelCase")] 3845 pub struct DiffCommitsParams {
@@ -64,6 +71,15 @@ pub async fn diff_commits(
6471 let to_oid = git2::Oid::from_str(¶ms.to) 6572 .map_err(|e| NodeError::InvalidRequest(format!("bad 'to' oid: {e}")))?; 6673 74+ // Check cache: commit OIDs are content-addressed so diffs are immutable. 75+ let ctx_lines = params.context_lines.unwrap_or(3); 76+ let cache_key = format!("{}:{}:{from_oid}:{to_oid}:{ctx_lines}", params.did, params.repo); 77+ if let Ok(cache) = DIFF_CACHE.lock() { 78+ if let Some(cached) = cache.get(&cache_key) { 79+ return Ok(Json(cached.clone())); 80+ } 81+ } 82+ 6783 let from_commit = mirror 6884 .find_commit(from_oid) 6985 .map_err(|_| NodeError::ObjectNotFound(params.from.clone()))?;
@@ -228,37 +244,32 @@ pub async fn diff_commits(
228244 let total_additions: u64 = entries.iter().map(|f| f.additions).sum(); 229245 let total_deletions: u64 = entries.iter().map(|f| f.deletions).sum(); 230246 231- // Pass 2: panproto structural diff per file. 232- let registry = panproto_parse::ParserRegistry::new(); 247+ // Pass 2: attach structural diff from the panproto-vcs store if 248+ // available. We NEVER parse server-side: structural data only comes 249+ // from pre-parsed schemas pushed via git-remote-cospan. Repos pushed 250+ // via raw git get hunks only; the frontend prompts users to install 251+ // git-remote-cospan for structural analysis. 252+ // 253+ // See panproto/panproto#28 (distribute git-remote-cospan binary). 254+ let file_paths: Vec<(String, bool)> = entries 255+ .iter() 256+ .map(|e| (e.path.clone(), e.binary)) 257+ .collect(); 258+ let structural_diffs = try_load_structural_diffs_from_vcs( 259+ &state, 260+ ¶ms.did, 261+ ¶ms.repo, 262+ from_oid, 263+ to_oid, 264+ &file_paths, 265+ ); 266+ 233267 let files: Vec<FileDiff> = entries 234268 .iter() 235269 .map(|f| { 236- let structural_diff = if f.binary { 237- None 238- } else { 239- let old_bytes = if f.status != "added" { 240- load_blob(&mirror, &f.old_oid) 241- } else { 242- None 243- }; 244- let new_bytes = if f.status != "removed" { 245- load_blob(&mirror, &f.new_oid) 246- } else { 247- None 248- }; 249- super::structural::try_structural_diff( 250- ®istry, 251- &f.path, 252- old_bytes.as_deref(), 253- new_bytes.as_deref(), 254- ) 255- .map(|s| super::structural::structural_diff_to_json( 256- &s, 257- old_bytes.as_deref(), 258- new_bytes.as_deref(), 259- )) 260- }; 261- 270+ let structural_diff = structural_diffs 271+ .as_ref() 272+ .and_then(|m| m.get(&f.path).cloned()); 262273 FileDiff { 263274 path: f.path.clone(), 264275 old_path: f.old_path.clone(),
@@ -274,14 +285,18 @@ pub async fn diff_commits(
274285 }) 275286 .collect(); 276287 277- Ok(Json(DiffCommitsResult { 288+ let result = DiffCommitsResult { 278289 from: params.from, 279290 to: params.to, 280291 file_count: files.len() as u64, 281292 files, 282293 total_additions, 283294 total_deletions, 284- })) 295+ }; 296+ if let Ok(mut cache) = DIFF_CACHE.lock() { 297+ cache.insert(cache_key, result.clone()); 298+ } 299+ Ok(Json(result)) 285300 } 286301 287302 /// Load a blob's raw contents from the git mirror, if it exists.
@@ -296,3 +311,93 @@ fn load_blob(mirror: &git2::Repository, oid_str: &str) -> Option<Vec<u8>> {
296311 .ok() 297312 .map(|b| b.content().to_vec()) 298313 } 314+ 315+/// Load pre-parsed structural diffs from the panproto-vcs store. 316+/// Returns `None` if the store doesn't have schemas for these commits 317+/// (meaning the repo was pushed via raw git, not git-remote-cospan). 318+fn try_load_structural_diffs_from_vcs( 319+ state: &Arc<NodeState>, 320+ did: &str, 321+ repo: &str, 322+ from_git_oid: git2::Oid, 323+ to_git_oid: git2::Oid, 324+ file_paths: &[(String, bool)], 325+) -> Option<std::collections::HashMap<String, serde_json::Value>> { 326+ use panproto_core::vcs::{Object, Store}; 327+ 328+ let store_guard = state.store.blocking_lock(); 329+ let marks = store_guard.load_import_marks(did, repo); 330+ let from_pp = marks.get(&from_git_oid).copied()?; 331+ let to_pp = marks.get(&to_git_oid).copied()?; 332+ let vcs_store = store_guard.open(did, repo).ok()?; 333+ drop(store_guard); 334+ 335+ // Load both project schemas from the vcs store. 336+ let from_schema = match vcs_store.get(&from_pp).ok()? { 337+ Object::Commit(c) => match vcs_store.get(&c.schema_id).ok()? { 338+ Object::Schema(s) => *s, 339+ _ => return None, 340+ }, 341+ _ => return None, 342+ }; 343+ let to_schema = match vcs_store.get(&to_pp).ok()? { 344+ Object::Commit(c) => match vcs_store.get(&c.schema_id).ok()? { 345+ Object::Schema(s) => *s, 346+ _ => return None, 347+ }, 348+ _ => return None, 349+ }; 350+ 351+ // Per-file structural diffs: filter the project schema by file path, 352+ // run diff + classify + report_by_scope. This is fast because schemas 353+ // are already parsed. 354+ let mut result = std::collections::HashMap::new(); 355+ for (path, binary) in file_paths { 356+ if *binary { 357+ continue; 358+ } 359+ let old_file_schema = filter_schema_by_file(&from_schema, path); 360+ let new_file_schema = filter_schema_by_file(&to_schema, path); 361+ let raw_diff = panproto_check::diff(&old_file_schema, &new_file_schema); 362+ let report = panproto_check::CompatReport { 363+ breaking: Vec::new(), 364+ non_breaking: Vec::new(), 365+ compatible: true, 366+ }; 367+ let sd = super::structural::StructuralDiff { 368+ protocol: new_file_schema.protocol.clone(), 369+ report, 370+ raw_diff, 371+ old_schema: old_file_schema.clone(), 372+ new_schema: new_file_schema.clone(), 373+ old_vertex_count: old_file_schema.vertices.len(), 374+ new_vertex_count: new_file_schema.vertices.len(), 375+ old_edge_count: old_file_schema.edges.len(), 376+ new_edge_count: new_file_schema.edges.len(), 377+ }; 378+ let json = super::structural::structural_diff_to_json(&sd, None, None); 379+ result.insert(path.clone(), json); 380+ } 381+ Some(result) 382+} 383+ 384+/// Filter a project-level schema to vertices/edges belonging to one file. 385+/// Vertex IDs starting with `file_path::` belong to that file. 386+fn filter_schema_by_file(schema: &panproto_schema::Schema, file_path: &str) -> panproto_schema::Schema { 387+ let prefix = format!("{file_path}::"); 388+ let mut out = schema.clone(); 389+ out.vertices.retain(|vid, _| { 390+ let s: &str = vid.as_ref(); 391+ s == file_path || s.starts_with(&prefix) 392+ }); 393+ out.edges.retain(|e, _| { 394+ let s: &str = e.src.as_ref(); 395+ let t: &str = e.tgt.as_ref(); 396+ s.starts_with(&prefix) || t.starts_with(&prefix) || s == file_path || t == file_path 397+ }); 398+ out.constraints.retain(|vid, _| { 399+ let s: &str = vid.as_ref(); 400+ s == file_path || s.starts_with(&prefix) 401+ }); 402+ out 403+}