feat: schematic VCS — commit graph, structural diff, git object copy Builds out the core showcase feature: actually visualizing version control graphs and schematic diffs end-to-end. This lands a complete stack from cospan-node through the appview to the frontend, with test coverage at every layer. ## cospan-node - Persistent bare git mirror per repo at {repo_dir}/.git-mirror, written to on receive-pack and read from on info-refs / upload-pack. The old implementation re-exported panproto-vcs objects into an ephemeral git repo per request, which was non-deterministic (different git OIDs each call) and broke `git clone`. The mirror is now the source of truth for git smart HTTP and the panproto-vcs store is updated alongside it. - Fixed receive-pack to index packfiles via git2 packwriter (was just writing them to disk unindexed, so subsequent lookups failed). - Fixed upload-pack to build packs by revwalk + insert_walk (was using insert_commit which only packs the commit object, not its tree and blobs — clients got "wrong pack signature"). - Fixed upload-pack's want/have parser to handle space-separated capabilities per the git wire protocol v1 spec (was looking for a NUL separator, which is the receive-pack convention). - Fixed info-refs to serve an empty ref list for non-existent repos under receive-pack so clients can init-on-first-push. - Fixed RepoManager::list_refs to scope to refs/ instead of walking the entire store root (was reading binary object files as UTF-8). - listCommits handler: walks the git mirror via RevWalk + topological + time sort, returns commits with parent pointers, author, message, timestamp, tree OID. Up to 500 commits per call. - diffCommits handler: git2 tree-to-tree diff returning per-file status, line-level hunks, additions/deletions counts. ## AppView - Authenticated fork handler: now requires a real OAuth session, derives the user's DID from the session (not the request body), writes dev.cospan.repo to the user's PDS via OAuth/DPoP with nonce retry, assigns the fork to the configured default node, and spawns a background git copy task tracked in the new fork_jobs table. - auth::pds_client module: authenticated PDS write helper. Adds DpopKey::create_resource_proof with the ath claim required for resource-server DPoP requests. - git_copy module: libgit2-based source-to-destination git mirror (temp bare repo → fetch all refs → push to destination). Handles Tangled knots, cospan nodes, or any git smart HTTP endpoint. - node_proxy::proxy_get_json helper for XRPC endpoints not yet modeled in panproto-xrpc's typed NodeClient. Tracked upstream at https://github.com/panproto/panproto/issues/25. - New proxy endpoints: dev.cospan.node.proxy.{listCommits,diffCommits}. - AppError::Upstream variant (502 Bad Gateway) for PDS/node failures. - AppConfig::default_node_{did,url} for fork placement. ## Frontend - lib/api/vcs.ts: typed Commit / DiffFile / DiffHunk types, listCommits and diffCommits client functions, and layoutCommitGraph — the standard lane-assignment algorithm producing GraphNode / GraphEdge tuples for SVG rendering. - CommitGraph.svelte: SVG commit DAG with lanes, S-curve edges between parent/child commits across lanes, and hollow ring markers for merge commits. Clickable rows link to the commit detail page. - StructuralDiff.svelte: GitHub-style file diff view with collapsible per-file hunks, colored add/remove/modify/rename badges, and line-by-line unified diff with old/new line numbers. - Repo overview page: renders CommitGraph when the hosting node serves listCommits, falls back to the flat ref-update CommitList for Tangled-hosted or unreachable repos. - Compare page: replaces the "structural diff viewer will be displayed here once the panproto-wasm module is integrated" TODO placeholder with the real StructuralDiff component. - Commit detail page: now takes git OIDs (matching CommitGraph links), shows the commit header and a first-parent diff via StructuralDiff. ## Test infrastructure - tests/support/test_pds.rs: an in-process Rust test PDS implementing com.atproto.repo.{createRecord,getRecord,listRecords,deleteRecord, describeRepo}. Validates DPoP proofs at the protocol level: JWT shape, typ=dpop+jwt, alg=ES256, htm matches request method, ath claim matches SHA-256 of the access token. Isolated in-memory store per test. - tests/support/test_node.rs: spawns in-process cospan-node instances for source/destination git operations. seed_git_repo and seed_git_repo_with_history both wrap blocking git2 work in spawn_blocking so callers can use the single-thread tokio runtime without deadlocking the axum server. - docker-compose.test.yml: tmpfs-backed Postgres on port 5433 with up/down/status scripts. - pnpm scripts: test:db:up, test:db:down, test:appview, test, test:all. - New fork e2e test: spawns test PDS + source node + destination node, seeds a git repo, forks with a real session cookie, verifies the PDS got an authenticated createRecord, waits for the background fork_job, clones from the destination and asserts file contents match. - listCommits tests: linear history returns commits newest-first with parent pointers; 404 for unknown repos. - diffCommits tests: added/modified/removed file statuses, hunk-level line content, no-changes case for identical commits. 45 tests passing across cospan-appview and cospan-node. ## Panproto bump Bumped workspace panproto deps from v0.25.0 to v0.27.3.

Author: Aaron Steven White
Commit 9bf60cc5bcf0119a07b92e2e61327f8add6e7c65
Parent: e2b90655d4
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
33 files changed +3718 -567
@@ -540,9 +540,11 @@ dependencies = [
540540  "axum",
541541  "base64",
542542  "chrono",
543+ "cospan-node",
543544  "dotenvy",
544545  "elliptic-curve",
545546  "futures-util",
547+ "git2",
546548  "jsonwebtoken",
547549  "mockall",
548550  "moka",
@@ -1479,7 +1481,7 @@ dependencies = [
14791481  "libc",
14801482  "percent-encoding",
14811483  "pin-project-lite",
1482- "socket2 0.6.3",
1484+ "socket2 0.5.10",
14831485  "system-configuration",
14841486  "tokio",
14851487  "tower-service",
@@ -2488,8 +2490,8 @@ dependencies = [
24882490 
24892491 [[package]]
24902492 name = "panproto-check"
2491-version = "0.25.0"
2492-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2493+version = "0.27.3"
2494+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
24932495 dependencies = [
24942496  "panproto-gat",
24952497  "panproto-lens",
@@ -2503,8 +2505,8 @@ dependencies = [
25032505 
25042506 [[package]]
25052507 name = "panproto-core"
2506-version = "0.25.0"
2507-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2508+version = "0.27.3"
2509+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
25082510 dependencies = [
25092511  "panproto-check",
25102512  "panproto-gat",
@@ -2519,8 +2521,8 @@ dependencies = [
25192521 
25202522 [[package]]
25212523 name = "panproto-expr"
2522-version = "0.25.0"
2523-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2524+version = "0.27.3"
2525+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
25242526 dependencies = [
25252527  "rustc-hash",
25262528  "serde",
@@ -2529,8 +2531,8 @@ dependencies = [
25292531 
25302532 [[package]]
25312533 name = "panproto-expr-parser"
2532-version = "0.25.0"
2533-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2534+version = "0.27.3"
2535+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
25342536 dependencies = [
25352537  "chumsky",
25362538  "logos 0.16.1",
@@ -2539,8 +2541,8 @@ dependencies = [
25392541 
25402542 [[package]]
25412543 name = "panproto-gat"
2542-version = "0.25.0"
2543-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2544+version = "0.27.3"
2545+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
25442546 dependencies = [
25452547  "panproto-expr",
25462548  "rustc-hash",
@@ -2550,8 +2552,8 @@ dependencies = [
25502552 
25512553 [[package]]
25522554 name = "panproto-git"
2553-version = "0.25.0"
2554-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2555+version = "0.27.3"
2556+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
25552557 dependencies = [
25562558  "git2",
25572559  "miette",
@@ -2568,8 +2570,8 @@ dependencies = [
25682570 
25692571 [[package]]
25702572 name = "panproto-grammars"
2571-version = "0.25.0"
2572-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2573+version = "0.27.3"
2574+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
25732575 dependencies = [
25742576  "cc",
25752577  "serde",
@@ -2580,8 +2582,8 @@ dependencies = [
25802582 
25812583 [[package]]
25822584 name = "panproto-inst"
2583-version = "0.25.0"
2584-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2585+version = "0.27.3"
2586+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
25852587 dependencies = [
25862588  "bumpalo",
25872589  "panproto-expr",
@@ -2596,8 +2598,8 @@ dependencies = [
25962598 
25972599 [[package]]
25982600 name = "panproto-io"
2599-version = "0.25.0"
2600-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2601+version = "0.27.3"
2602+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
26012603 dependencies = [
26022604  "bumpalo",
26032605  "memchr",
@@ -2619,8 +2621,8 @@ dependencies = [
26192621 
26202622 [[package]]
26212623 name = "panproto-lens"
2622-version = "0.25.0"
2623-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2624+version = "0.27.3"
2625+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
26242626 dependencies = [
26252627  "panproto-expr",
26262628  "panproto-gat",
@@ -2636,8 +2638,8 @@ dependencies = [
26362638 
26372639 [[package]]
26382640 name = "panproto-lens-dsl"
2639-version = "0.25.0"
2640-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2641+version = "0.27.3"
2642+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
26412643 dependencies = [
26422644  "miette",
26432645  "nickel-lang",
@@ -2655,8 +2657,8 @@ dependencies = [
26552657 
26562658 [[package]]
26572659 name = "panproto-mig"
2658-version = "0.25.0"
2659-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2660+version = "0.27.3"
2661+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
26602662 dependencies = [
26612663  "panproto-expr",
26622664  "panproto-gat",
@@ -2669,8 +2671,8 @@ dependencies = [
26692671 
26702672 [[package]]
26712673 name = "panproto-parse"
2672-version = "0.25.0"
2673-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2674+version = "0.27.3"
2675+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
26742676 dependencies = [
26752677  "memchr",
26762678  "miette",
@@ -2688,8 +2690,8 @@ dependencies = [
26882690 
26892691 [[package]]
26902692 name = "panproto-project"
2691-version = "0.25.0"
2692-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2693+version = "0.27.3"
2694+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
26932695 dependencies = [
26942696  "blake3",
26952697  "globset",
@@ -2708,8 +2710,8 @@ dependencies = [
27082710 
27092711 [[package]]
27102712 name = "panproto-protocols"
2711-version = "0.25.0"
2712-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2713+version = "0.27.3"
2714+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
27132715 dependencies = [
27142716  "blake3",
27152717  "panproto-gat",
@@ -2723,8 +2725,8 @@ dependencies = [
27232725 
27242726 [[package]]
27252727 name = "panproto-schema"
2726-version = "0.25.0"
2727-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2728+version = "0.27.3"
2729+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
27282730 dependencies = [
27292731  "panproto-expr",
27302732  "panproto-gat",
@@ -2736,8 +2738,8 @@ dependencies = [
27362738 
27372739 [[package]]
27382740 name = "panproto-vcs"
2739-version = "0.25.0"
2740-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2741+version = "0.27.3"
2742+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
27412743 dependencies = [
27422744  "blake3",
27432745  "panproto-check",
@@ -2757,8 +2759,8 @@ dependencies = [
27572759 
27582760 [[package]]
27592761 name = "panproto-xrpc"
2760-version = "0.25.0"
2761-source = "git+https://github.com/panproto/panproto.git?tag=v0.25.0#3e97d4272dde94e5d66d2da82fcc1623d0b7ca3a"
2762+version = "0.27.3"
2763+source = "git+https://github.com/panproto/panproto.git?tag=v0.27.3#8d55ceface4d59752903f996c1ae34935b50cbea"
27622764 dependencies = [
27632765  "hex",
27642766  "miette",
@@ -3061,7 +3063,7 @@ dependencies = [
30613063  "quinn-udp",
30623064  "rustc-hash",
30633065  "rustls",
3064- "socket2 0.6.3",
3066+ "socket2 0.5.10",
30653067  "thiserror",
30663068  "tokio",
30673069  "tracing",
@@ -3098,7 +3100,7 @@ dependencies = [
30983100  "cfg_aliases",
30993101  "libc",
31003102  "once_cell",
3101- "socket2 0.6.3",
3103+ "socket2 0.5.10",
31023104  "tracing",
31033105  "windows-sys 0.60.2",
31043106 ]
@@ -3929,6 +3931,7 @@ dependencies = [
39293931  "tokio-stream",
39303932  "tracing",
39313933  "url",
3934+ "uuid",
39323935 ]
39333936 
39343937 [[package]]
@@ -4009,6 +4012,7 @@ dependencies = [
40094012  "stringprep",
40104013  "thiserror",
40114014  "tracing",
4015+ "uuid",
40124016  "whoami",
40134017 ]
40144018 
@@ -4047,6 +4051,7 @@ dependencies = [
40474051  "stringprep",
40484052  "thiserror",
40494053  "tracing",
4054+ "uuid",
40504055  "whoami",
40514056 ]
40524057 
@@ -4073,6 +4078,7 @@ dependencies = [
40734078  "thiserror",
40744079  "tracing",
40754080  "url",
4081+ "uuid",
40764082 ]
40774083 
40784084 [[package]]
@@ -4855,6 +4861,7 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
48554861 dependencies = [
48564862  "getrandom 0.4.2",
48574863  "js-sys",
4864+ "serde_core",
48584865  "wasm-bindgen",
48594866 ]
48604867 
@@ -5099,7 +5106,7 @@ version = "0.1.11"
50995106 source = "registry+https://github.com/rust-lang/crates.io-index"
51005107 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
51015108 dependencies = [
5102- "windows-sys 0.61.2",
5109+ "windows-sys 0.48.0",
51035110 ]
51045111 
51055112 [[package]]
@@ -13,24 +13,24 @@ license = "AGPL-3.0-or-later"
1313 repository = "https://github.com/cospan-dev/cospan"
1414 
1515 [workspace.dependencies]
16-# panproto v0.25.0 — includes panproto-lens-dsl
17-panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
18-panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
19-panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
20-panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
21-panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
22-panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
23-panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
24-panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
25-panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
26-panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
27-panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
28-panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
29-panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
30-panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
31-panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0", features = ["group-all"] }
32-panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
33-panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.25.0" }
16+# panproto v0.27.3
17+panproto-core = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
18+panproto-vcs = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
19+panproto-schema = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
20+panproto-check = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
21+panproto-lens = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
22+panproto-lens-dsl = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
23+panproto-protocols = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
24+panproto-io = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
25+panproto-inst = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
26+panproto-gat = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
27+panproto-mig = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
28+panproto-expr = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
29+panproto-expr-parser = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
30+panproto-xrpc = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
31+panproto-parse = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3", features = ["group-all"] }
32+panproto-git = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
33+panproto-project = { git = "https://github.com/panproto/panproto.git", tag = "v0.27.3" }
3434 git2 = "0.20"
3535 
3636 # Web framework
@@ -45,7 +45,7 @@ serde_json = "1"
4545 rmp-serde = "1"
4646 
4747 # Database
48-sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "migrate", "json", "chrono"] }
48+sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "migrate", "json", "chrono", "uuid"] }
4949 
5050 # Crypto / identity
5151 blake3 = "1"
@@ -79,7 +79,7 @@ tree-sitter = "0.25"
7979 # General
8080 chrono = { version = "0.4", features = ["serde"] }
8181 thiserror = "2"
82-uuid = { version = "1", features = ["v4"] }
82+uuid = { version = "1", features = ["v4", "serde"] }
8383 toml = "0.8"
8484 dotenvy = "0.15"
8585 async-trait = "0.1"
@@ -0,0 +1,227 @@
1+/**
2+ * VCS operations: commit walking, diffs, graph data.
3+ *
4+ * These call the appview's node-proxy endpoints which in turn forward
5+ * to the cospan-node hosting the repo. The frontend never needs to
6+ * know node URLs.
7+ */
8+
9+import { xrpcQuery } from './client.js';
10+
11+export interface CommitAuthor {
12+	name: string;
13+	email: string;
14+}
15+
16+export interface Commit {
17+	oid: string;
18+	parents: string[];
19+	summary: string;
20+	message: string;
21+	author: CommitAuthor;
22+	committer: CommitAuthor;
23+	timestamp: number;
24+	treeOid: string;
25+}
26+
27+export interface ListCommitsResponse {
28+	commits: Commit[];
29+	count: number;
30+	start: string;
31+}
32+
33+export interface DiffLine {
34+	origin: ' ' | '+' | '-' | string;
35+	content: string;
36+	oldLineno: number | null;
37+	newLineno: number | null;
38+}
39+
40+export interface DiffHunk {
41+	oldStart: number;
42+	oldLines: number;
43+	newStart: number;
44+	newLines: number;
45+	header: string;
46+	lines: DiffLine[];
47+}
48+
49+export type DiffStatus =
50+	| 'added'
51+	| 'removed'
52+	| 'modified'
53+	| 'renamed'
54+	| 'copied'
55+	| 'typechange';
56+
57+export interface DiffFile {
58+	path: string;
59+	oldPath: string | null;
60+	status: DiffStatus;
61+	oldOid: string;
62+	newOid: string;
63+	additions: number;
64+	deletions: number;
65+	binary: boolean;
66+	hunks: DiffHunk[];
67+}
68+
69+export interface DiffCommitsResponse {
70+	from: string;
71+	to: string;
72+	files: DiffFile[];
73+	totalAdditions: number;
74+	totalDeletions: number;
75+	fileCount: number;
76+}
77+
78+export function listCommits(params: {
79+	did: string;
80+	repo: string;
81+	ref?: string;
82+	limit?: number;
83+}): Promise<ListCommitsResponse> {
84+	const query: Record<string, string | number | undefined> = {
85+		did: params.did,
86+		repo: params.repo,
87+	};
88+	if (params.ref) query.ref = params.ref;
89+	if (params.limit) query.limit = params.limit;
90+	return xrpcQuery<ListCommitsResponse>('dev.cospan.node.proxy.listCommits', query);
91+}
92+
93+export function diffCommits(params: {
94+	did: string;
95+	repo: string;
96+	from: string;
97+	to: string;
98+	contextLines?: number;
99+}): Promise<DiffCommitsResponse> {
100+	const query: Record<string, string | number | undefined> = {
101+		did: params.did,
102+		repo: params.repo,
103+		from: params.from,
104+		to: params.to,
105+	};
106+	if (params.contextLines !== undefined) query.contextLines = params.contextLines;
107+	return xrpcQuery<DiffCommitsResponse>('dev.cospan.node.proxy.diffCommits', query);
108+}
109+
110+// ─── Commit-graph lane assignment ──────────────────────────────────
111+//
112+// Given a list of commits in topological+time order (newest first),
113+// assign each commit to a lane such that parent-child edges don't
114+// cross. This is the standard git-log --graph algorithm:
115+//
116+// 1. Maintain a sparse array `lanes` where each index is a lane
117+//    position and the value is the OID of the commit whose child
118+//    we're waiting to see. Start empty.
119+// 2. For each commit in order:
120+//    a. Find its lane (by OID) in `lanes`, or the leftmost empty
121+//       slot if none exists.
122+//    b. Place the commit there.
123+//    c. Remove it from `lanes`, then push its parents into the
124+//       first free slots (preferring the commit's own lane for the
125+//       first parent to keep chains straight).
126+// 3. Collect (fromLane, toLane) edges for each parent link.
127+
128+export interface GraphNode {
129+	commit: Commit;
130+	lane: number;
131+	row: number;
132+}
133+
134+export interface GraphEdge {
135+	fromRow: number;
136+	toRow: number;
137+	fromLane: number;
138+	toLane: number;
139+}
140+
141+export interface CommitGraphLayout {
142+	nodes: GraphNode[];
143+	edges: GraphEdge[];
144+	laneCount: number;
145+}
146+
147+export function layoutCommitGraph(commits: Commit[]): CommitGraphLayout {
148+	const nodes: GraphNode[] = [];
149+	const edges: GraphEdge[] = [];
150+	// `lanes[i]` holds the OID of a commit whose child has been placed
151+	// and is "waiting" for its parent to appear at lane i. A null slot
152+	// is free.
153+	let lanes: (string | null)[] = [];
154+	// row in the graph for each OID (so we can build edges).
155+	const oidRow = new Map<string, number>();
156+	const oidLane = new Map<string, number>();
157+
158+	for (let row = 0; row < commits.length; row++) {
159+		const commit = commits[row];
160+		const oid = commit.oid;
161+
162+		// Step a: find this commit's lane.
163+		let myLane = lanes.findIndex((l) => l === oid);
164+		if (myLane === -1) {
165+			// Not waited-for (e.g. a tip). Place in the leftmost free slot,
166+			// or append.
167+			myLane = lanes.findIndex((l) => l === null);
168+			if (myLane === -1) {
169+				myLane = lanes.length;
170+				lanes.push(null);
171+			}
172+		}
173+
174+		nodes.push({ commit, lane: myLane, row });
175+		oidRow.set(oid, row);
176+		oidLane.set(oid, myLane);
177+
178+		// Step b: remove this commit from the lane and push its parents.
179+		lanes[myLane] = null;
180+
181+		// For each parent, add it to the lanes list, preferring this
182+		// commit's own lane for the first parent.
183+		commit.parents.forEach((parent, idx) => {
184+			if (idx === 0) {
185+				// First parent inherits this lane.
186+				lanes[myLane] = parent;
187+			} else {
188+				// Additional parents (merge commits) go to the first free
189+				// slot, or append.
190+				let freeIdx = lanes.findIndex((l) => l === null);
191+				if (freeIdx === -1) {
192+					freeIdx = lanes.length;
193+					lanes.push(null);
194+				}
195+				lanes[freeIdx] = parent;
196+			}
197+		});
198+
199+		// Compact trailing null slots.
200+		while (lanes.length > 0 && lanes[lanes.length - 1] === null) {
201+			lanes.pop();
202+		}
203+	}
204+
205+	// Second pass: build edges.
206+	// A parent-to-child edge runs from the parent commit's row/lane
207+	// (if it's in `nodes`) up to the child's row/lane.
208+	for (const node of nodes) {
209+		for (const parentOid of node.commit.parents) {
210+			const parentRow = oidRow.get(parentOid);
211+			if (parentRow === undefined) continue; // parent is off-screen
212+			const parentLane = oidLane.get(parentOid);
213+			if (parentLane === undefined) continue;
214+			edges.push({
215+				fromRow: node.row,
216+				toRow: parentRow,
217+				fromLane: node.lane,
218+				toLane: parentLane,
219+			});
220+		}
221+	}
222+
223+	// Lane count = max lane + 1 across all nodes, or 0.
224+	const laneCount = nodes.reduce((max, n) => Math.max(max, n.lane + 1), 0);
225+
226+	return { nodes, edges, laneCount };
227+}
@@ -0,0 +1,188 @@
1+<script lang="ts">
2+	import { onMount } from 'svelte';
3+	import type { Commit, CommitGraphLayout } from '$lib/api/vcs.js';
4+	import { layoutCommitGraph } from '$lib/api/vcs.js';
5+	import { resolveHandle } from '$lib/api/handle.js';
6+
7+	let {
8+		commits,
9+		basePath = '',
10+		commitUrlBase = ''
11+	}: {
12+		commits: Commit[];
13+		basePath?: string;
14+		commitUrlBase?: string;
15+	} = $props();
16+
17+	// Resolve committer emails → DIDs → handles. For now we just show
18+	// the author.name from the git commit, which for Cospan-created
19+	// commits is the authenticated DID.
20+	let handles = $state<Record<string, string>>({});
21+
22+	onMount(async () => {
23+		const candidates = new Set<string>();
24+		for (const c of commits) {
25+			if (c.author?.name?.startsWith('did:')) candidates.add(c.author.name);
26+			if (c.committer?.name?.startsWith('did:')) candidates.add(c.committer.name);
27+		}
28+		const resolved: Record<string, string> = {};
29+		await Promise.allSettled(
30+			Array.from(candidates).map(async (did) => {
31+				resolved[did] = await resolveHandle(did);
32+			})
33+		);
34+		handles = resolved;
35+	});
36+
37+	function displayAuthor(c: Commit): string {
38+		const name = c.author?.name ?? '';
39+		if (name.startsWith('did:')) {
40+			return handles[name] || name.slice(8, 18) + '…';
41+		}
42+		return name || c.author?.email || 'unknown';
43+	}
44+
45+	function formatTime(unixSeconds: number): string {
46+		if (!unixSeconds) return '';
47+		const d = new Date(unixSeconds * 1000);
48+		return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
49+	}
50+
51+	function truncate(oid: string): string {
52+		return oid.slice(0, 8);
53+	}
54+
55+	// Build layout from the commits list.
56+	let layout: CommitGraphLayout = $derived(layoutCommitGraph(commits));
57+
58+	// Lane colours — same palette as the design system's accent family.
59+	const LANE_COLORS = [
60+		'#6366f1', // indigo
61+		'#10b981', // emerald
62+		'#f59e0b', // amber
63+		'#ec4899', // pink
64+		'#8b5cf6', // violet
65+		'#14b8a6', // teal
66+		'#f97316', // orange
67+		'#06b6d4', // cyan
68+	];
69+
70+	function laneColor(lane: number): string {
71+		return LANE_COLORS[lane % LANE_COLORS.length];
72+	}
73+
74+	// Geometry
75+	const ROW_HEIGHT = 36;
76+	const LANE_WIDTH = 18;
77+	const NODE_RADIUS = 5;
78+	const LEFT_PADDING = 14;
79+
80+	function laneX(lane: number): number {
81+		return LEFT_PADDING + lane * LANE_WIDTH;
82+	}
83+
84+	function rowY(row: number): number {
85+		return ROW_HEIGHT / 2 + row * ROW_HEIGHT;
86+	}
87+
88+	let graphWidth = $derived(Math.max(1, layout.laneCount) * LANE_WIDTH + LEFT_PADDING * 2);
89+	let graphHeight = $derived(Math.max(1, commits.length) * ROW_HEIGHT);
90+
91+	// For each edge, build an SVG path. If fromLane == toLane, it's a
92+	// straight vertical line. Otherwise, we route it as an S-curve:
93+	// vertical from the child down to the midpoint, then curve over to
94+	// the parent's lane, then vertical down to the parent.
95+	function edgePath(fromRow: number, toRow: number, fromLane: number, toLane: number): string {
96+		const x1 = laneX(fromLane);
97+		const y1 = rowY(fromRow) + NODE_RADIUS;
98+		const x2 = laneX(toLane);
99+		const y2 = rowY(toRow) - NODE_RADIUS;
100+		if (fromLane === toLane) {
101+			return `M ${x1} ${y1} L ${x2} ${y2}`;
102+		}
103+		// S-curve via a cubic Bezier. Control points at the lane midpoints
104+		// create a smooth diagonal when lanes shift by one.
105+		const midY = (y1 + y2) / 2;
106+		return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
107+	}
108+
109+	function commitHref(oid: string): string {
110+		if (commitUrlBase) return `${commitUrlBase}/${oid}`;
111+		return `${basePath}/commit/${oid}`;
112+	}
113+</script>
114+
115+{#if commits.length === 0}
116+	<p class="py-8 text-center text-sm text-text-secondary">No commits yet.</p>
117+{:else}
118+	<div class="flex">
119+		<!-- SVG graph column -->
120+		<svg
121+			width={graphWidth}
122+			height={graphHeight}
123+			class="shrink-0"
124+			role="img"
125+			aria-label="Commit graph"
126+		>
127+			<!-- Edges first so nodes sit on top -->
128+			{#each layout.edges as edge (edge.fromRow + '_' + edge.toRow + '_' + edge.fromLane + '_' + edge.toLane)}
129+				<path
130+					d={edgePath(edge.fromRow, edge.toRow, edge.fromLane, edge.toLane)}
131+					stroke={laneColor(edge.fromLane === edge.toLane ? edge.fromLane : edge.toLane)}
132+					stroke-width="2"
133+					fill="none"
134+					stroke-linecap="round"
135+					opacity="0.85"
136+				/>
137+			{/each}
138+
139+			<!-- Nodes -->
140+			{#each layout.nodes as node (node.commit.oid)}
141+				<circle
142+					cx={laneX(node.lane)}
143+					cy={rowY(node.row)}
144+					r={NODE_RADIUS}
145+					fill={laneColor(node.lane)}
146+					stroke="var(--color-surface-1, #0a0a0a)"
147+					stroke-width="2"
148+				>
149+					<title>{node.commit.oid}</title>
150+				</circle>
151+				{#if node.commit.parents.length >= 2}
152+					<!-- Merge commits get a hollow ring -->
153+					<circle
154+						cx={laneX(node.lane)}
155+						cy={rowY(node.row)}
156+						r={NODE_RADIUS - 1.5}
157+						fill="var(--color-surface-1, #0a0a0a)"
158+					/>
159+				{/if}
160+			{/each}
161+		</svg>
162+
163+		<!-- Commit rows, aligned 1:1 with graph rows -->
164+		<ul class="flex-1 min-w-0">
165+			{#each layout.nodes as node (node.commit.oid)}
166+				<li style="height: {ROW_HEIGHT}px;" class="flex items-center">
167+					<a
168+						href={commitHref(node.commit.oid)}
169+						class="group flex min-w-0 flex-1 items-center gap-3 rounded px-2 py-1 transition-colors hover:bg-surface-2"
170+					>
171+						<span class="min-w-0 flex-1 truncate text-sm text-text-primary">
172+							{node.commit.summary || '(no message)'}
173+						</span>
174+						<code class="shrink-0 font-mono text-[11px] text-text-muted group-hover:text-accent">
175+							{truncate(node.commit.oid)}
176+						</code>
177+						<span class="shrink-0 text-[11px] text-text-muted hidden sm:inline">
178+							{displayAuthor(node.commit)}
179+						</span>
180+						<time class="shrink-0 text-[11px] text-text-muted hidden md:inline">
181+							{formatTime(node.commit.timestamp)}
182+						</time>
183+					</a>
184+				</li>
185+			{/each}
186+		</ul>
187+	</div>
188+{/if}
@@ -0,0 +1,147 @@
1+<script lang="ts">
2+	import type { DiffCommitsResponse, DiffFile, DiffLine } from '$lib/api/vcs.js';
3+
4+	let { diff }: { diff: DiffCommitsResponse } = $props();
5+
6+	// Per-file collapse state. Modified files start expanded, others collapsed.
7+	let openPaths = $state(
8+		new Set<string>(
9+			diff.files
10+				.filter((f) => f.status === 'modified' || f.status === 'added' || f.status === 'removed')
11+				.slice(0, 5)
12+				.map((f) => f.path)
13+		)
14+	);
15+
16+	function toggle(path: string) {
17+		const next = new Set(openPaths);
18+		if (next.has(path)) next.delete(path);
19+		else next.add(path);
20+		openPaths = next;
21+	}
22+
23+	function statusBadge(status: string): { bg: string; fg: string; label: string } {
24+		switch (status) {
25+			case 'added':
26+				return { bg: 'bg-compatible/15', fg: 'text-compatible', label: 'added' };
27+			case 'removed':
28+				return { bg: 'bg-breaking/15', fg: 'text-breaking', label: 'removed' };
29+			case 'renamed':
30+				return { bg: 'bg-info/15', fg: 'text-info', label: 'renamed' };
31+			case 'copied':
32+				return { bg: 'bg-info/15', fg: 'text-info', label: 'copied' };
33+			case 'typechange':
34+				return { bg: 'bg-warning/15', fg: 'text-warning', label: 'typechange' };
35+			default:
36+				return { bg: 'bg-surface-2', fg: 'text-text-secondary', label: 'modified' };
37+		}
38+	}
39+
40+	function lineClass(origin: string): string {
41+		switch (origin) {
42+			case '+':
43+				return 'bg-compatible/10 text-compatible';
44+			case '-':
45+				return 'bg-breaking/10 text-breaking';
46+			default:
47+				return 'text-text-secondary';
48+		}
49+	}
50+
51+	function linePrefix(origin: string): string {
52+		if (origin === '+') return '+';
53+		if (origin === '-') return '-';
54+		return ' ';
55+	}
56+
57+	function stripNewline(s: string): string {
58+		return s.replace(/\n$/, '');
59+	}
60+</script>
61+
62+{#if diff.files.length === 0}
63+	<div class="rounded-lg border border-border bg-surface-1 p-8 text-center">
64+		<p class="text-sm text-text-secondary">No changes between these commits.</p>
65+	</div>
66+{:else}
67+	<!-- Summary bar -->
68+	<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">
69+		<span class="font-medium text-text-primary">
70+			{diff.fileCount} {diff.fileCount === 1 ? 'file' : 'files'} changed
71+		</span>
72+		<span class="flex items-center gap-1 text-compatible">
73+			<span class="font-mono">+{diff.totalAdditions}</span>
74+		</span>
75+		<span class="flex items-center gap-1 text-breaking">
76+			<span class="font-mono">−{diff.totalDeletions}</span>
77+		</span>
78+	</div>
79+
80+	<!-- File list -->
81+	<div class="space-y-3">
82+		{#each diff.files as file (file.path)}
83+			{@const badge = statusBadge(file.status)}
84+			{@const isOpen = openPaths.has(file.path)}
85+			<div class="overflow-hidden rounded-lg border border-border bg-surface-1">
86+				<!-- File header -->
87+				<button
88+					type="button"
89+					class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-2"
90+					onclick={() => toggle(file.path)}
91+				>
92+					<svg
93+						class="h-4 w-4 shrink-0 text-text-muted transition-transform {isOpen ? 'rotate-90' : ''}"
94+						fill="none"
95+						viewBox="0 0 24 24"
96+						stroke="currentColor"
97+						stroke-width="2"
98+					>
99+						<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
100+					</svg>
101+					<span class="rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider {badge.bg} {badge.fg}">
102+						{badge.label}
103+					</span>
104+					<code class="min-w-0 flex-1 truncate font-mono text-sm text-text-primary">
105+						{#if file.oldPath && file.oldPath !== file.path}
106+							<span class="text-text-muted">{file.oldPath} → </span>
107+						{/if}
108+						{file.path}
109+					</code>
110+					{#if file.additions > 0}
111+						<span class="shrink-0 font-mono text-xs text-compatible">+{file.additions}</span>
112+					{/if}
113+					{#if file.deletions > 0}
114+						<span class="shrink-0 font-mono text-xs text-breaking">−{file.deletions}</span>
115+					{/if}
116+				</button>
117+
118+				<!-- Hunks -->
119+				{#if isOpen}
120+					{#if file.binary}
121+						<div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted">
122+							Binary file — no diff shown
123+						</div>
124+					{:else if file.hunks.length === 0}
125+						<div class="border-t border-border bg-surface-0 px-4 py-6 text-center text-xs text-text-muted">
126+							{file.status === 'added'
127+								? 'New empty file'
128+								: file.status === 'removed'
129+									? 'File deleted — no content to show'
130+									: 'No content changes (mode or metadata only)'}
131+						</div>
132+					{:else}
133+						{#each file.hunks as hunk, h (h)}
134+							<div class="border-t border-border">
135+								<div class="bg-surface-2 px-4 py-1.5 font-mono text-[11px] text-text-muted">
136+									{hunk.header.replace(/\n$/, '')}
137+								</div>
138+								<pre class="overflow-x-auto bg-surface-0 font-mono text-[12px] leading-5">{#each hunk.lines as line, l (l)}<span class={lineClass(line.origin)}><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.oldLineno ?? ''}</span><span class="inline-block w-10 select-none text-right pr-2 text-text-muted">{line.newLineno ?? ''}</span><span class="inline-block w-4 select-none">{linePrefix(line.origin)}</span>{stripNewline(line.content)}
139+</span>{/each}</pre>
140+							</div>
141+						{/each}
142+					{/if}
143+				{/if}
144+			</div>
145+		{/each}
146+	</div>
147+{/if}
cospan · schematic version control on atproto built on AT Protocol