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
9bf60cc5bcf0119a07b92e2e61327f8add6e7c65Parent: 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-cospan33 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}