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 13d2117dd9bd22e59e28d13464d0086c29d58378
Parent: 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-cospan
7 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(&params.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+        &params.did,
261+        &params.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-                    &registry,
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+}
cospan · schematic version control on atproto built on AT Protocol