diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..69e42b3 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,12 @@ +[target.wasm32-unknown-emscripten] +rustflags = [ + "-C", "link-arg=-sINVOKE_RUN=0", + "-C", "link-arg=-sEXIT_RUNTIME=1", + "-C", "link-arg=-sMODULARIZE=1", + "-C", "link-arg=-sEXPORT_NAME=createPathModule", + "-C", "link-arg=-sEXPORTED_RUNTIME_METHODS=callMain,FS", + "-C", "link-arg=-sFORCE_FILESYSTEM=1", + "-C", "link-arg=-sALLOW_MEMORY_GROWTH=1", + "-C", "link-arg=-sENVIRONMENT=web", + "-C", "link-arg=-sSTACK_SIZE=1048576", +] diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000..7990c0b --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,118 @@ +name: CI + Deploy + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-test-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-test- + + - name: Test + run: cargo test --workspace + + - name: Clippy + run: cargo clippy --workspace -- -D warnings + + deploy: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-emscripten + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-wasm-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-wasm- + + - name: Cache emsdk + uses: actions/cache@v4 + with: + path: local/emsdk + key: emsdk-latest + + - name: Build wasm + run: scripts/build-wasm.sh + + - uses: pnpm/action-setup@v4 + with: + version: latest + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: site/pnpm-lock.yaml + + - name: Build site + run: cd site && pnpm install && pnpm run build + + - name: Deploy to Cloudflare Pages + id: deploy + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy site/_site --project-name=toolpath --branch=${{ github.head_ref || 'main' }} + + - name: Comment preview URL on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const body = `πŸ” **Preview deployed:** ${process.env.DEPLOY_URL}`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes('Preview deployed:')); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + env: + DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }} diff --git a/.gitignore b/.gitignore index 9f3716f..60893b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /local +site/wasm/ diff --git a/Cargo.toml b/Cargo.toml index 250cf97..ee21c04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ license = "Apache-2.0" [workspace.dependencies] toolpath = { version = "0.1.3", path = "crates/toolpath" } toolpath-git = { version = "0.1.2", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.1.2", path = "crates/toolpath-claude" } +toolpath-claude = { version = "0.1.2", path = "crates/toolpath-claude", default-features = false } toolpath-dot = { version = "0.1.2", path = "crates/toolpath-dot" } serde = { version = "1.0", features = ["derive"] } @@ -29,3 +29,10 @@ tokio = { version = "1.40", features = ["full"] } notify = { version = "7", features = ["macos_kqueue"] } similar = "2" tempfile = "3.15" + +[profile.wasm] +inherits = "release" +opt-level = "z" +lto = true +codegen-units = 1 +strip = true diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 94ad3ca..3275010 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -15,7 +15,6 @@ path = "src/main.rs" [dependencies] toolpath = { workspace = true } toolpath-git = { workspace = true } -toolpath-claude = { workspace = true } toolpath-dot = { workspace = true } clap = { workspace = true } anyhow = { workspace = true } @@ -24,7 +23,13 @@ serde_json = { workspace = true } similar = { workspace = true } chrono = { workspace = true } tempfile = { workspace = true } -git2 = { workspace = true } rand = "0.9" +[target.'cfg(not(target_os = "emscripten"))'.dependencies] +toolpath-claude = { workspace = true, features = ["watcher"] } +git2 = { workspace = true } + +[target.'cfg(target_os = "emscripten")'.dependencies] +toolpath-claude = { workspace = true } + [dev-dependencies] diff --git a/crates/toolpath-cli/src/cmd_derive.rs b/crates/toolpath-cli/src/cmd_derive.rs index 23e9ba9..9688863 100644 --- a/crates/toolpath-cli/src/cmd_derive.rs +++ b/crates/toolpath-cli/src/cmd_derive.rs @@ -1,4 +1,6 @@ -use anyhow::{Context, Result}; +#[cfg(not(target_os = "emscripten"))] +use anyhow::Context; +use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; @@ -67,31 +69,42 @@ fn run_git( title: Option, pretty: bool, ) -> Result<()> { - let repo_path = if repo_path.is_absolute() { - repo_path - } else { - std::env::current_dir()?.join(&repo_path) - }; + #[cfg(target_os = "emscripten")] + { + let _ = (repo_path, branches, base, remote, title, pretty); + anyhow::bail!( + "'path derive git' requires a native environment with access to a git repository" + ); + } - let repo = git2::Repository::open(&repo_path) - .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; + #[cfg(not(target_os = "emscripten"))] + { + let repo_path = if repo_path.is_absolute() { + repo_path + } else { + std::env::current_dir()?.join(&repo_path) + }; - let config = toolpath_git::DeriveConfig { - remote, - title, - base, - }; + let repo = git2::Repository::open(&repo_path) + .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; + + let config = toolpath_git::DeriveConfig { + remote, + title, + base, + }; - let doc = toolpath_git::derive(&repo, &branches, &config)?; + let doc = toolpath_git::derive(&repo, &branches, &config)?; - let json = if pretty { - doc.to_json_pretty()? - } else { - doc.to_json()? - }; + let json = if pretty { + doc.to_json_pretty()? + } else { + doc.to_json()? + }; - println!("{}", json); - Ok(()) + println!("{}", json); + Ok(()) + } } fn run_claude(project: String, session: Option, all: bool, pretty: bool) -> Result<()> { @@ -143,7 +156,7 @@ fn run_claude_with_manager( Ok(()) } -#[cfg(test)] +#[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; diff --git a/crates/toolpath-cli/src/cmd_list.rs b/crates/toolpath-cli/src/cmd_list.rs index 893b024..97cbfe2 100644 --- a/crates/toolpath-cli/src/cmd_list.rs +++ b/crates/toolpath-cli/src/cmd_list.rs @@ -1,4 +1,6 @@ -use anyhow::{Context, Result}; +#[cfg(not(target_os = "emscripten"))] +use anyhow::Context; +use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; @@ -30,49 +32,60 @@ pub fn run(source: ListSource, json: bool) -> Result<()> { } fn run_git(repo_path: PathBuf, remote: String, json: bool) -> Result<()> { - let repo_path = if repo_path.is_absolute() { - repo_path - } else { - std::env::current_dir()?.join(&repo_path) - }; - - let repo = git2::Repository::open(&repo_path) - .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; - - let uri = toolpath_git::get_repo_uri(&repo, &remote)?; - let branches = toolpath_git::list_branches(&repo)?; + #[cfg(target_os = "emscripten")] + { + let _ = (repo_path, remote, json); + anyhow::bail!( + "'path list git' requires a native environment with access to a git repository" + ); + } - if json { - let items: Vec = branches - .iter() - .map(|b| { - serde_json::json!({ - "name": b.name, - "head": b.head, - "subject": b.subject, - "author": b.author, - "timestamp": b.timestamp, + #[cfg(not(target_os = "emscripten"))] + { + let repo_path = if repo_path.is_absolute() { + repo_path + } else { + std::env::current_dir()?.join(&repo_path) + }; + + let repo = git2::Repository::open(&repo_path) + .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; + + let uri = toolpath_git::get_repo_uri(&repo, &remote)?; + let branches = toolpath_git::list_branches(&repo)?; + + if json { + let items: Vec = branches + .iter() + .map(|b| { + serde_json::json!({ + "name": b.name, + "head": b.head, + "subject": b.subject, + "author": b.author, + "timestamp": b.timestamp, + }) }) - }) - .collect(); - let output = serde_json::json!({ - "source": "git", - "uri": uri, - "branches": items, - }); - println!("{}", serde_json::to_string_pretty(&output)?); - } else { - println!("Repository: {}", uri); - println!(); - if branches.is_empty() { - println!(" (no local branches)"); + .collect(); + let output = serde_json::json!({ + "source": "git", + "uri": uri, + "branches": items, + }); + println!("{}", serde_json::to_string_pretty(&output)?); } else { - for b in &branches { - println!(" {} {} {}", b.head_short, b.name, truncate(&b.subject, 60)); + println!("Repository: {}", uri); + println!(); + if branches.is_empty() { + println!(" (no local branches)"); + } else { + for b in &branches { + println!(" {} {} {}", b.head_short, b.name, truncate(&b.subject, 60)); + } } } + Ok(()) } - Ok(()) } fn run_claude(project: Option, json: bool) -> Result<()> { @@ -167,7 +180,7 @@ fn truncate(s: &str, max: usize) -> String { } } -#[cfg(test)] +#[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; diff --git a/crates/toolpath-cli/src/main.rs b/crates/toolpath-cli/src/main.rs index f7f2cbd..fb6ef38 100644 --- a/crates/toolpath-cli/src/main.rs +++ b/crates/toolpath-cli/src/main.rs @@ -12,7 +12,7 @@ use clap::{Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser, Debug)] -#[command(name = "path")] +#[command(name = "path", version)] #[command(about = "Derive, query, and visualize Toolpath provenance documents")] struct Cli { #[command(subcommand)] diff --git a/crates/toolpath-git/Cargo.toml b/crates/toolpath-git/Cargo.toml index 4dad749..f5a27d3 100644 --- a/crates/toolpath-git/Cargo.toml +++ b/crates/toolpath-git/Cargo.toml @@ -10,9 +10,11 @@ categories = ["development-tools"] [dependencies] toolpath = { workspace = true } -git2 = { workspace = true } chrono = { workspace = true } anyhow = { workspace = true } +[target.'cfg(not(target_os = "emscripten"))'.dependencies] +git2 = { workspace = true } + [dev-dependencies] tempfile = "3" diff --git a/crates/toolpath-git/src/lib.rs b/crates/toolpath-git/src/lib.rs index 42adae1..23d9784 100644 --- a/crates/toolpath-git/src/lib.rs +++ b/crates/toolpath-git/src/lib.rs @@ -1,16 +1,7 @@ #![doc = include_str!("../README.md")] -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use git2::{Commit, DiffOptions, Oid, Repository}; -use std::collections::HashMap; -use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity, - Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource, -}; - // ============================================================================ -// Public configuration and types +// Public configuration and types (available on all targets) // ============================================================================ /// Configuration for deriving Toolpath documents from a git repository. @@ -52,184 +43,27 @@ impl BranchSpec { } } -// ============================================================================ -// Public API -// ============================================================================ - -/// Derive a Toolpath [`Document`] from the given repository and branch names. -/// -/// Branch strings are parsed as [`BranchSpec`]s (supporting `"name:start"` syntax). -/// A single branch produces a [`Document::Path`]; multiple branches produce a -/// [`Document::Graph`]. -pub fn derive(repo: &Repository, branches: &[String], config: &DeriveConfig) -> Result { - let branch_specs: Vec = branches.iter().map(|s| BranchSpec::parse(s)).collect(); - - if branch_specs.len() == 1 { - let path_doc = derive_path(repo, &branch_specs[0], config)?; - Ok(Document::Path(path_doc)) - } else { - let graph_doc = derive_graph(repo, &branch_specs, config)?; - Ok(Document::Graph(graph_doc)) - } -} - -/// Derive a Toolpath [`Path`] from a single branch specification. -pub fn derive_path(repo: &Repository, spec: &BranchSpec, config: &DeriveConfig) -> Result { - let repo_uri = get_repo_uri(repo, &config.remote)?; - - let branch_ref = repo - .find_branch(&spec.name, git2::BranchType::Local) - .with_context(|| format!("Branch '{}' not found", spec.name))?; - let branch_commit = branch_ref.get().peel_to_commit()?; - - // Determine base commit - let base_oid = if let Some(global_base) = &config.base { - // Global base overrides per-branch - let obj = repo - .revparse_single(global_base) - .with_context(|| format!("Failed to parse base ref '{}'", global_base))?; - obj.peel_to_commit()?.id() - } else if let Some(start) = &spec.start { - // Per-branch start commit - resolve relative to the branch - // e.g., "main:HEAD~5" means 5 commits before main's HEAD - let start_ref = if let Some(rest) = start.strip_prefix("HEAD") { - // Replace HEAD with the branch name for relative refs - format!("{}{}", spec.name, rest) - } else { - start.clone() - }; - let obj = repo.revparse_single(&start_ref).with_context(|| { - format!( - "Failed to parse start ref '{}' (resolved to '{}') for branch '{}'", - start, start_ref, spec.name - ) - })?; - obj.peel_to_commit()?.id() - } else { - // Default: find merge-base with default branch - find_base_for_branch(repo, &branch_commit)? - }; - - let base_commit = repo.find_commit(base_oid)?; - - // Collect commits from base to head - let commits = collect_commits(repo, base_oid, branch_commit.id())?; - - // Generate steps and collect actor definitions - let mut actors: HashMap = HashMap::new(); - let steps = generate_steps(repo, &commits, base_oid, &mut actors)?; - - // Build path document - let head_step_id = if steps.is_empty() { - format!("step-{}", short_oid(branch_commit.id())) - } else { - steps.last().unwrap().step.id.clone() - }; - - Ok(Path { - path: PathIdentity { - id: format!("path-{}", spec.name.replace('/', "-")), - base: Some(Base { - uri: repo_uri, - ref_str: Some(base_commit.id().to_string()), - }), - head: head_step_id, - }, - steps, - meta: Some(PathMeta { - title: Some(format!("Branch: {}", spec.name)), - actors: if actors.is_empty() { - None - } else { - Some(actors) - }, - ..Default::default() - }), - }) -} - -/// Derive a Toolpath [`Graph`] from multiple branch specifications. -pub fn derive_graph( - repo: &Repository, - branch_specs: &[BranchSpec], - config: &DeriveConfig, -) -> Result { - // Find the default branch name - let default_branch = find_default_branch(repo); - - // If the default branch is included without an explicit start, compute the earliest - // merge-base among all other branches to use as its starting point - let default_branch_start = compute_default_branch_start(repo, branch_specs, &default_branch)?; - - // Generate paths for each branch with its own base - let mut paths = Vec::new(); - for spec in branch_specs { - // Check if this is the default branch and needs special handling - let effective_spec = if default_branch_start.is_some() - && spec.start.is_none() - && default_branch.as_ref() == Some(&spec.name) - { - BranchSpec { - name: spec.name.clone(), - start: default_branch_start.clone(), - } - } else { - spec.clone() - }; - let path_doc = derive_path(repo, &effective_spec, config)?; - paths.push(PathOrRef::Path(Box::new(path_doc))); - } - - // Create graph ID from branch names - let branch_names: Vec<&str> = branch_specs.iter().map(|s| s.name.as_str()).collect(); - let graph_id = if branch_names.len() <= 3 { - format!( - "graph-{}", - branch_names - .iter() - .map(|b| b.replace('/', "-")) - .collect::>() - .join("-") - ) - } else { - format!("graph-{}-branches", branch_names.len()) - }; - - let title = config - .title - .clone() - .unwrap_or_else(|| format!("Branches: {}", branch_names.join(", "))); - - Ok(Graph { - graph: GraphIdentity { id: graph_id }, - paths, - meta: Some(GraphMeta { - title: Some(title), - ..Default::default() - }), - }) +/// Summary information about a local branch. +#[derive(Debug, Clone)] +pub struct BranchInfo { + /// Branch name (e.g., "main", "feature/foo"). + pub name: String, + /// Short (8-char) hex of the tip commit. + pub head_short: String, + /// Full hex OID of the tip commit. + pub head: String, + /// First line of the tip commit message. + pub subject: String, + /// Author name of the tip commit. + pub author: String, + /// ISO 8601 timestamp of the tip commit. + pub timestamp: String, } // ============================================================================ -// Public utility functions +// Public utility functions (pure, available on all targets) // ============================================================================ -/// Get the repository URI from a remote, falling back to a file:// URI. -pub fn get_repo_uri(repo: &Repository, remote_name: &str) -> Result { - if let Ok(remote) = repo.find_remote(remote_name) - && let Some(url) = remote.url() - { - return Ok(normalize_git_url(url)); - } - - // Fall back to file path - if let Some(path) = repo.path().parent() { - return Ok(format!("file://{}", path.display())); - } - - Ok("file://unknown".to_string()) -} - /// Normalize a git remote URL to a canonical short form. /// /// Converts common hosting URLs to compact identifiers: @@ -312,328 +146,480 @@ pub fn slugify_author(name: &str, email: &str) -> String { } // ============================================================================ -// Listing / discovery +// git2-dependent code (native targets only) // ============================================================================ -/// Summary information about a local branch. -#[derive(Debug, Clone)] -pub struct BranchInfo { - /// Branch name (e.g., "main", "feature/foo"). - pub name: String, - /// Short (8-char) hex of the tip commit. - pub head_short: String, - /// Full hex OID of the tip commit. - pub head: String, - /// First line of the tip commit message. - pub subject: String, - /// Author name of the tip commit. - pub author: String, - /// ISO 8601 timestamp of the tip commit. - pub timestamp: String, -} +#[cfg(not(target_os = "emscripten"))] +mod native { + use anyhow::{Context, Result}; + use chrono::{DateTime, Utc}; + use git2::{Commit, DiffOptions, Oid, Repository}; + use std::collections::HashMap; + use toolpath::v1::{ + ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity, + Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource, + }; -/// List local branches with summary metadata. -pub fn list_branches(repo: &Repository) -> Result> { - let mut branches = Vec::new(); + use super::{BranchInfo, BranchSpec, DeriveConfig}; - for branch_result in repo.branches(Some(git2::BranchType::Local))? { - let (branch, _) = branch_result?; - let name = branch.name()?.unwrap_or("").to_string(); + /// Derive a Toolpath [`Document`] from the given repository and branch names. + /// + /// Branch strings are parsed as [`BranchSpec`]s (supporting `"name:start"` syntax). + /// A single branch produces a [`Document::Path`]; multiple branches produce a + /// [`Document::Graph`]. + pub fn derive( + repo: &Repository, + branches: &[String], + config: &DeriveConfig, + ) -> Result { + let branch_specs: Vec = branches.iter().map(|s| BranchSpec::parse(s)).collect(); + + if branch_specs.len() == 1 { + let path_doc = derive_path(repo, &branch_specs[0], config)?; + Ok(Document::Path(path_doc)) + } else { + let graph_doc = derive_graph(repo, &branch_specs, config)?; + Ok(Document::Graph(graph_doc)) + } + } - let commit = branch.get().peel_to_commit()?; + /// Derive a Toolpath [`Path`] from a single branch specification. + pub fn derive_path( + repo: &Repository, + spec: &BranchSpec, + config: &DeriveConfig, + ) -> Result { + let repo_uri = get_repo_uri(repo, &config.remote)?; + + let branch_ref = repo + .find_branch(&spec.name, git2::BranchType::Local) + .with_context(|| format!("Branch '{}' not found", spec.name))?; + let branch_commit = branch_ref.get().peel_to_commit()?; + + // Determine base commit + let base_oid = if let Some(global_base) = &config.base { + // Global base overrides per-branch + let obj = repo + .revparse_single(global_base) + .with_context(|| format!("Failed to parse base ref '{}'", global_base))?; + obj.peel_to_commit()?.id() + } else if let Some(start) = &spec.start { + // Per-branch start commit - resolve relative to the branch + // e.g., "main:HEAD~5" means 5 commits before main's HEAD + let start_ref = if let Some(rest) = start.strip_prefix("HEAD") { + // Replace HEAD with the branch name for relative refs + format!("{}{}", spec.name, rest) + } else { + start.clone() + }; + let obj = repo.revparse_single(&start_ref).with_context(|| { + format!( + "Failed to parse start ref '{}' (resolved to '{}') for branch '{}'", + start, start_ref, spec.name + ) + })?; + obj.peel_to_commit()?.id() + } else { + // Default: find merge-base with default branch + find_base_for_branch(repo, &branch_commit)? + }; - let author = commit.author(); - let author_name = author.name().unwrap_or("unknown").to_string(); + let base_commit = repo.find_commit(base_oid)?; - let time = commit.time(); - let timestamp = DateTime::::from_timestamp(time.seconds(), 0) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + // Collect commits from base to head + let commits = collect_commits(repo, base_oid, branch_commit.id())?; - let subject = commit - .message() - .unwrap_or("") - .lines() - .next() - .unwrap_or("") - .to_string(); - - branches.push(BranchInfo { - name, - head_short: short_oid(commit.id()), - head: commit.id().to_string(), - subject, - author: author_name, - timestamp, - }); - } + // Generate steps and collect actor definitions + let mut actors: HashMap = HashMap::new(); + let steps = generate_steps(repo, &commits, base_oid, &mut actors)?; - branches.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(branches) -} + // Build path document + let head_step_id = if steps.is_empty() { + format!("step-{}", short_oid(branch_commit.id())) + } else { + steps.last().unwrap().step.id.clone() + }; -// ============================================================================ -// Private helpers -// ============================================================================ + Ok(Path { + path: PathIdentity { + id: format!("path-{}", spec.name.replace('/', "-")), + base: Some(Base { + uri: repo_uri, + ref_str: Some(base_commit.id().to_string()), + }), + head: head_step_id, + }, + steps, + meta: Some(PathMeta { + title: Some(format!("Branch: {}", spec.name)), + actors: if actors.is_empty() { + None + } else { + Some(actors) + }, + ..Default::default() + }), + }) + } -/// When the default branch is included in a multi-branch graph without an explicit start, -/// compute the earliest merge-base among all feature branches to use as main's start. -/// This ensures we see main's commits back to where the earliest feature diverged. -fn compute_default_branch_start( - repo: &Repository, - branch_specs: &[BranchSpec], - default_branch: &Option, -) -> Result> { - let default_name = match default_branch { - Some(name) => name, - None => return Ok(None), - }; + /// Derive a Toolpath [`Graph`] from multiple branch specifications. + pub fn derive_graph( + repo: &Repository, + branch_specs: &[BranchSpec], + config: &DeriveConfig, + ) -> Result { + // Find the default branch name + let default_branch = find_default_branch(repo); + + // If the default branch is included without an explicit start, compute the earliest + // merge-base among all other branches to use as its starting point + let default_branch_start = + compute_default_branch_start(repo, branch_specs, &default_branch)?; + + // Generate paths for each branch with its own base + let mut paths = Vec::new(); + for spec in branch_specs { + // Check if this is the default branch and needs special handling + let effective_spec = if default_branch_start.is_some() + && spec.start.is_none() + && default_branch.as_ref() == Some(&spec.name) + { + BranchSpec { + name: spec.name.clone(), + start: default_branch_start.clone(), + } + } else { + spec.clone() + }; + let path_doc = derive_path(repo, &effective_spec, config)?; + paths.push(PathOrRef::Path(Box::new(path_doc))); + } - // Check if the default branch is in the list and doesn't have an explicit start - let default_in_list = branch_specs - .iter() - .any(|s| &s.name == default_name && s.start.is_none()); - if !default_in_list { - return Ok(None); + // Create graph ID from branch names + let branch_names: Vec<&str> = branch_specs.iter().map(|s| s.name.as_str()).collect(); + let graph_id = if branch_names.len() <= 3 { + format!( + "graph-{}", + branch_names + .iter() + .map(|b| b.replace('/', "-")) + .collect::>() + .join("-") + ) + } else { + format!("graph-{}-branches", branch_names.len()) + }; + + let title = config + .title + .clone() + .unwrap_or_else(|| format!("Branches: {}", branch_names.join(", "))); + + Ok(Graph { + graph: GraphIdentity { id: graph_id }, + paths, + meta: Some(GraphMeta { + title: Some(title), + ..Default::default() + }), + }) } - // Get the default branch commit - let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?; - let default_commit = default_ref.get().peel_to_commit()?; + /// Get the repository URI from a remote, falling back to a file:// URI. + pub fn get_repo_uri(repo: &Repository, remote_name: &str) -> Result { + if let Ok(remote) = repo.find_remote(remote_name) + && let Some(url) = remote.url() + { + return Ok(super::normalize_git_url(url)); + } + + // Fall back to file path + if let Some(path) = repo.path().parent() { + return Ok(format!("file://{}", path.display())); + } - // Find the earliest merge-base among all non-default branches - let mut earliest_base: Option = None; + Ok("file://unknown".to_string()) + } - for spec in branch_specs { - if &spec.name == default_name { - continue; + /// List local branches with summary metadata. + pub fn list_branches(repo: &Repository) -> Result> { + let mut branches = Vec::new(); + + for branch_result in repo.branches(Some(git2::BranchType::Local))? { + let (branch, _) = branch_result?; + let name = branch.name()?.unwrap_or("").to_string(); + + let commit = branch.get().peel_to_commit()?; + + let author = commit.author(); + let author_name = author.name().unwrap_or("unknown").to_string(); + + let time = commit.time(); + let timestamp = DateTime::::from_timestamp(time.seconds(), 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + + let subject = commit + .message() + .unwrap_or("") + .lines() + .next() + .unwrap_or("") + .to_string(); + + branches.push(BranchInfo { + name, + head_short: short_oid(commit.id()), + head: commit.id().to_string(), + subject, + author: author_name, + timestamp, + }); } - let branch_ref = match repo.find_branch(&spec.name, git2::BranchType::Local) { - Ok(r) => r, - Err(_) => continue, - }; - let branch_commit = match branch_ref.get().peel_to_commit() { - Ok(c) => c, - Err(_) => continue, + branches.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(branches) + } + + // ======================================================================== + // Private helpers + // ======================================================================== + + fn compute_default_branch_start( + repo: &Repository, + branch_specs: &[BranchSpec], + default_branch: &Option, + ) -> Result> { + let default_name = match default_branch { + Some(name) => name, + None => return Ok(None), }; - if let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) { - // Check if this merge-base is earlier (ancestor of) current earliest - match earliest_base { - None => earliest_base = Some(merge_base), - Some(current) => { - // If merge_base is an ancestor of current, use merge_base - // (it's "earlier" in the commit history) - if repo.merge_base(merge_base, current).ok() == Some(merge_base) - && merge_base != current - { - earliest_base = Some(merge_base); + let default_in_list = branch_specs + .iter() + .any(|s| &s.name == default_name && s.start.is_none()); + if !default_in_list { + return Ok(None); + } + + let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?; + let default_commit = default_ref.get().peel_to_commit()?; + + let mut earliest_base: Option = None; + + for spec in branch_specs { + if &spec.name == default_name { + continue; + } + + let branch_ref = match repo.find_branch(&spec.name, git2::BranchType::Local) { + Ok(r) => r, + Err(_) => continue, + }; + let branch_commit = match branch_ref.get().peel_to_commit() { + Ok(c) => c, + Err(_) => continue, + }; + + if let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) { + match earliest_base { + None => earliest_base = Some(merge_base), + Some(current) => { + if repo.merge_base(merge_base, current).ok() == Some(merge_base) + && merge_base != current + { + earliest_base = Some(merge_base); + } } } } } - } - // Use the GRANDPARENT of the earliest merge-base so both the merge-base and its parent - // are included in main's steps. This avoids showing an orphan BASE node. - if let Some(base_oid) = earliest_base - && let Ok(base_commit) = repo.find_commit(base_oid) - && base_commit.parent_count() > 0 - && let Ok(parent) = base_commit.parent(0) - { - // Try to get grandparent - if parent.parent_count() > 0 - && let Ok(grandparent) = parent.parent(0) + if let Some(base_oid) = earliest_base + && let Ok(base_commit) = repo.find_commit(base_oid) + && base_commit.parent_count() > 0 + && let Ok(parent) = base_commit.parent(0) { - return Ok(Some(grandparent.id().to_string())); + if parent.parent_count() > 0 + && let Ok(grandparent) = parent.parent(0) + { + return Ok(Some(grandparent.id().to_string())); + } + return Ok(Some(parent.id().to_string())); } - // Fall back to parent if no grandparent - return Ok(Some(parent.id().to_string())); + + Ok(earliest_base.map(|oid| oid.to_string())) } - Ok(earliest_base.map(|oid| oid.to_string())) -} + fn find_base_for_branch(repo: &Repository, branch_commit: &Commit) -> Result { + if let Some(default_branch) = find_default_branch(repo) + && let Ok(default_ref) = repo.find_branch(&default_branch, git2::BranchType::Local) + && let Ok(default_commit) = default_ref.get().peel_to_commit() + && default_commit.id() != branch_commit.id() + && let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) + && merge_base != branch_commit.id() + { + return Ok(merge_base); + } -fn find_base_for_branch(repo: &Repository, branch_commit: &Commit) -> Result { - // Try to find merge-base with default branch, but only if the branch - // being derived is *not* the default branch itself (merge-base of a - // branch with itself is its own tip, which yields zero commits). - if let Some(default_branch) = find_default_branch(repo) - && let Ok(default_ref) = repo.find_branch(&default_branch, git2::BranchType::Local) - && let Ok(default_commit) = default_ref.get().peel_to_commit() - && default_commit.id() != branch_commit.id() - && let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) - && merge_base != branch_commit.id() - { - return Ok(merge_base); - } + let mut walker = repo.revwalk()?; + walker.push(branch_commit.id())?; + walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; - // Fall back to first commit in history (root of the branch) - let mut walker = repo.revwalk()?; - walker.push(branch_commit.id())?; - walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; + if let Some(Ok(oid)) = walker.next() { + return Ok(oid); + } - if let Some(Ok(oid)) = walker.next() { - return Ok(oid); + Ok(branch_commit.id()) } - Ok(branch_commit.id()) -} - -fn find_default_branch(repo: &Repository) -> Option { - // Try common default branch names - for name in &["main", "master", "trunk", "develop"] { - if repo.find_branch(name, git2::BranchType::Local).is_ok() { - return Some(name.to_string()); + fn find_default_branch(repo: &Repository) -> Option { + for name in &["main", "master", "trunk", "develop"] { + if repo.find_branch(name, git2::BranchType::Local).is_ok() { + return Some(name.to_string()); + } } + None } - None -} -fn collect_commits<'a>( - repo: &'a Repository, - base_oid: Oid, - head_oid: Oid, -) -> Result>> { - let mut walker = repo.revwalk()?; - walker.push(head_oid)?; - walker.hide(base_oid)?; - walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; - - let mut commits = Vec::new(); - for oid_result in walker { - let oid = oid_result?; - let commit = repo.find_commit(oid)?; - commits.push(commit); + fn collect_commits<'a>( + repo: &'a Repository, + base_oid: Oid, + head_oid: Oid, + ) -> Result>> { + let mut walker = repo.revwalk()?; + walker.push(head_oid)?; + walker.hide(base_oid)?; + walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; + + let mut commits = Vec::new(); + for oid_result in walker { + let oid = oid_result?; + let commit = repo.find_commit(oid)?; + commits.push(commit); + } + + Ok(commits) } - Ok(commits) -} + fn generate_steps( + repo: &Repository, + commits: &[Commit], + base_oid: Oid, + actors: &mut HashMap, + ) -> Result> { + let mut steps = Vec::new(); + + for commit in commits { + let step = commit_to_step(repo, commit, base_oid, actors)?; + steps.push(step); + } -fn generate_steps( - repo: &Repository, - commits: &[Commit], - base_oid: Oid, - actors: &mut HashMap, -) -> Result> { - let mut steps = Vec::new(); - - for commit in commits { - let step = commit_to_step(repo, commit, base_oid, actors)?; - steps.push(step); + Ok(steps) } - Ok(steps) -} + fn commit_to_step( + repo: &Repository, + commit: &Commit, + base_oid: Oid, + actors: &mut HashMap, + ) -> Result { + let step_id = format!("step-{}", short_oid(commit.id())); + + let parents: Vec = commit + .parent_ids() + .filter(|pid| *pid != base_oid) + .map(|pid| format!("step-{}", short_oid(pid))) + .collect(); -fn commit_to_step( - repo: &Repository, - commit: &Commit, - base_oid: Oid, - actors: &mut HashMap, -) -> Result { - let step_id = format!("step-{}", short_oid(commit.id())); - - // Filter parents to only include those that aren't the base commit - let parents: Vec = commit - .parent_ids() - .filter(|pid| *pid != base_oid) - .map(|pid| format!("step-{}", short_oid(pid))) - .collect(); - - // Get author info - let author = commit.author(); - let author_name = author.name().unwrap_or("unknown"); - let author_email = author.email().unwrap_or("unknown"); - let actor = format!("human:{}", slugify_author(author_name, author_email)); - - // Register actor definition - actors.entry(actor.clone()).or_insert_with(|| { - let mut identities = Vec::new(); - if author_email != "unknown" { - identities.push(Identity { - system: "email".to_string(), - id: author_email.to_string(), - }); - } - ActorDefinition { - name: Some(author_name.to_string()), - identities, - ..Default::default() - } - }); + let author = commit.author(); + let author_name = author.name().unwrap_or("unknown"); + let author_email = author.email().unwrap_or("unknown"); + let actor = format!("human:{}", super::slugify_author(author_name, author_email)); + + actors.entry(actor.clone()).or_insert_with(|| { + let mut identities = Vec::new(); + if author_email != "unknown" { + identities.push(Identity { + system: "email".to_string(), + id: author_email.to_string(), + }); + } + ActorDefinition { + name: Some(author_name.to_string()), + identities, + ..Default::default() + } + }); - // Get timestamp - let time = commit.time(); - let timestamp = DateTime::::from_timestamp(time.seconds(), 0) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + let time = commit.time(); + let timestamp = DateTime::::from_timestamp(time.seconds(), 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); - // Generate diff - let change = generate_diff(repo, commit)?; + let change = generate_diff(repo, commit)?; - // Get commit message as intent - let message = commit.message().unwrap_or("").trim(); - let intent = if message.is_empty() { - None - } else { - // Use first line of commit message - Some(message.lines().next().unwrap_or(message).to_string()) - }; + let message = commit.message().unwrap_or("").trim(); + let intent = if message.is_empty() { + None + } else { + Some(message.lines().next().unwrap_or(message).to_string()) + }; - // VCS source reference - let source = VcsSource { - vcs_type: "git".to_string(), - revision: commit.id().to_string(), - change_id: None, - }; + let source = VcsSource { + vcs_type: "git".to_string(), + revision: commit.id().to_string(), + change_id: None, + }; - Ok(Step { - step: StepIdentity { - id: step_id, - parents, - actor, - timestamp, - }, - change, - meta: Some(StepMeta { - intent, - source: Some(source), - ..Default::default() - }), - }) -} + Ok(Step { + step: StepIdentity { + id: step_id, + parents, + actor, + timestamp, + }, + change, + meta: Some(StepMeta { + intent, + source: Some(source), + ..Default::default() + }), + }) + } -fn generate_diff(repo: &Repository, commit: &Commit) -> Result> { - let tree = commit.tree()?; + fn generate_diff( + repo: &Repository, + commit: &Commit, + ) -> Result> { + let tree = commit.tree()?; - let parent_tree = if commit.parent_count() > 0 { - Some(commit.parent(0)?.tree()?) - } else { - None - }; + let parent_tree = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; - let mut diff_opts = DiffOptions::new(); - diff_opts.context_lines(3); + let mut diff_opts = DiffOptions::new(); + diff_opts.context_lines(3); - let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?; + let diff = + repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?; - let mut changes: HashMap = HashMap::new(); - let mut current_file: Option = None; - let mut current_diff = String::new(); + let mut changes: HashMap = HashMap::new(); + let mut current_file: Option = None; + let mut current_diff = String::new(); - diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { - let file_path = delta - .new_file() - .path() - .or_else(|| delta.old_file().path()) - .map(|p| p.to_string_lossy().to_string()); + diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { + let file_path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()); - if let Some(path) = file_path { - // Check if we're starting a new file - if current_file.as_ref() != Some(&path) { - // Save previous file's diff + if let Some(path) = file_path + && current_file.as_ref() != Some(&path) + { if let Some(prev_file) = current_file.take() && !current_diff.is_empty() { @@ -642,56 +628,394 @@ fn generate_diff(repo: &Repository, commit: &Commit) -> Result "+", - '-' => "-", - ' ' => " ", - '>' => ">", - '<' => "<", - 'F' => "", // File header - 'H' => "@", // Hunk header - we'll handle this specially - 'B' => "", - _ => "", - }; - if line.origin() == 'H' { - // Hunk header - if let Ok(content) = std::str::from_utf8(line.content()) { - current_diff.push_str("@@"); - current_diff.push_str(content.trim_start_matches('@')); + let prefix = match line.origin() { + '+' => "+", + '-' => "-", + ' ' => " ", + '>' => ">", + '<' => "<", + 'F' => "", + 'H' => "@", + 'B' => "", + _ => "", + }; + + if line.origin() == 'H' { + if let Ok(content) = std::str::from_utf8(line.content()) { + current_diff.push_str("@@"); + current_diff.push_str(content.trim_start_matches('@')); + } + } else if (!prefix.is_empty() || line.origin() == ' ') + && let Ok(content) = std::str::from_utf8(line.content()) + { + current_diff.push_str(prefix); + current_diff.push_str(content); } - } else if (!prefix.is_empty() || line.origin() == ' ') - && let Ok(content) = std::str::from_utf8(line.content()) + + true + })?; + + if let Some(file) = current_file + && !current_diff.is_empty() { - current_diff.push_str(prefix); - current_diff.push_str(content); + changes.insert(file, ArtifactChange::raw(¤t_diff)); } - true - })?; + Ok(changes) + } - // Don't forget the last file - if let Some(file) = current_file - && !current_diff.is_empty() - { - changes.insert(file, ArtifactChange::raw(¤t_diff)); + fn short_oid(oid: Oid) -> String { + safe_prefix(&oid.to_string(), 8) } - Ok(changes) -} + fn safe_prefix(s: &str, n: usize) -> String { + s.chars().take(n).collect() + } -fn short_oid(oid: Oid) -> String { - safe_prefix(&oid.to_string(), 8) -} + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_safe_prefix_ascii() { + assert_eq!(safe_prefix("abcdef12345", 8), "abcdef12"); + } + + #[test] + fn test_safe_prefix_short_string() { + assert_eq!(safe_prefix("abc", 8), "abc"); + } + + #[test] + fn test_safe_prefix_empty() { + assert_eq!(safe_prefix("", 8), ""); + } + + #[test] + fn test_safe_prefix_multibyte() { + assert_eq!(safe_prefix("cafΓ©", 3), "caf"); + assert_eq!(safe_prefix("ζ—₯本θͺžγƒ†γ‚Ήγƒˆ", 3), "ζ—₯本θͺž"); + } + + #[test] + fn test_short_oid() { + let oid = Oid::from_str("abcdef1234567890abcdef1234567890abcdef12").unwrap(); + assert_eq!(short_oid(oid), "abcdef12"); + } + + fn init_temp_repo() -> (tempfile::TempDir, Repository) { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test User").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + + (dir, repo) + } + + fn create_commit( + repo: &Repository, + message: &str, + file_name: &str, + content: &str, + parent: Option<&git2::Commit>, + ) -> Oid { + let mut index = repo.index().unwrap(); + let file_path = repo.workdir().unwrap().join(file_name); + std::fs::write(&file_path, content).unwrap(); + index.add_path(std::path::Path::new(file_name)).unwrap(); + index.write().unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = repo.signature().unwrap(); + let parents: Vec<&git2::Commit> = parent.into_iter().collect(); + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) + .unwrap() + } + + #[test] + fn test_list_branches_on_repo() { + let (_dir, repo) = init_temp_repo(); + create_commit(&repo, "initial", "file.txt", "hello", None); + + let branches = list_branches(&repo).unwrap(); + assert!(!branches.is_empty()); + let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); + assert!( + names.contains(&"main") || names.contains(&"master"), + "Expected main or master in {:?}", + names + ); + } + + #[test] + fn test_list_branches_sorted() { + let (_dir, repo) = init_temp_repo(); + let oid = create_commit(&repo, "initial", "file.txt", "hello", None); + let commit = repo.find_commit(oid).unwrap(); + + repo.branch("b-beta", &commit, false).unwrap(); + repo.branch("a-alpha", &commit, false).unwrap(); + + let branches = list_branches(&repo).unwrap(); + let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); + } + + #[test] + fn test_get_repo_uri_no_remote() { + let (_dir, repo) = init_temp_repo(); + let uri = get_repo_uri(&repo, "origin").unwrap(); + assert!( + uri.starts_with("file://"), + "Expected file:// URI, got {}", + uri + ); + } + + #[test] + fn test_derive_single_branch() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); + + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: None, + }; + + let default = find_default_branch(&repo).unwrap_or("main".to_string()); + let result = derive(&repo, &[default], &config).unwrap(); + + match result { + Document::Path(path) => { + assert!(!path.steps.is_empty(), "Expected at least one step"); + assert!(path.path.base.is_some()); + } + _ => panic!("Expected Document::Path for single branch"), + } + } + + #[test] + fn test_derive_multiple_branches_produces_graph() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let _oid2 = create_commit(&repo, "on default", "file.txt", "v2", Some(&commit1)); + + let default_branch = find_default_branch(&repo).unwrap(); + + repo.branch("feature", &commit1, false).unwrap(); + repo.set_head("refs/heads/feature").unwrap(); + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) + .unwrap(); + let commit1_again = repo.find_commit(oid1).unwrap(); + create_commit( + &repo, + "feature work", + "feature.txt", + "feat", + Some(&commit1_again), + ); + + repo.set_head(&format!("refs/heads/{}", default_branch)) + .unwrap(); + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) + .unwrap(); + + let config = DeriveConfig { + remote: "origin".to_string(), + title: Some("Test Graph".to_string()), + base: None, + }; + + let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap(); + + match result { + Document::Graph(graph) => { + assert_eq!(graph.paths.len(), 2); + assert!(graph.meta.is_some()); + assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph"); + } + _ => panic!("Expected Document::Graph for multiple branches"), + } + } -/// Return the first `n` characters of a string, safe for any UTF-8 content. -fn safe_prefix(s: &str, n: usize) -> String { - s.chars().take(n).collect() + #[test] + fn test_find_default_branch() { + let (_dir, repo) = init_temp_repo(); + create_commit(&repo, "initial", "file.txt", "hello", None); + + let default = find_default_branch(&repo); + assert!(default.is_some()); + let name = default.unwrap(); + assert!(name == "main" || name == "master"); + } + + #[test] + fn test_branch_info_fields() { + let (_dir, repo) = init_temp_repo(); + create_commit(&repo, "test subject line", "file.txt", "hello", None); + + let branches = list_branches(&repo).unwrap(); + let branch = &branches[0]; + + assert!(!branch.head.is_empty()); + assert_eq!(branch.head_short.len(), 8); + assert_eq!(branch.subject, "test subject line"); + assert_eq!(branch.author, "Test User"); + assert!(branch.timestamp.ends_with('Z')); + } + + #[test] + fn test_derive_with_global_base() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let oid2 = create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); + let commit2 = repo.find_commit(oid2).unwrap(); + create_commit(&repo, "third commit", "file.txt", "v3", Some(&commit2)); + + let default = find_default_branch(&repo).unwrap(); + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: Some(oid1.to_string()), + }; + + let result = derive(&repo, &[default], &config).unwrap(); + match result { + Document::Path(path) => { + assert!(path.steps.len() >= 1); + } + _ => panic!("Expected Document::Path"), + } + } + + #[test] + fn test_derive_path_with_branch_start() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); + let commit2 = repo.find_commit(oid2).unwrap(); + create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); + + let default = find_default_branch(&repo).unwrap(); + let spec = BranchSpec { + name: default, + start: Some(oid1.to_string()), + }; + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: None, + }; + + let path = derive_path(&repo, &spec, &config).unwrap(); + assert!(path.steps.len() >= 1); + } + + #[test] + fn test_generate_diff_initial_commit() { + let (_dir, repo) = init_temp_repo(); + let oid = create_commit(&repo, "initial", "file.txt", "hello world", None); + let commit = repo.find_commit(oid).unwrap(); + + let changes = generate_diff(&repo, &commit).unwrap(); + assert!(!changes.is_empty()); + assert!(changes.contains_key("file.txt")); + } + + #[test] + fn test_collect_commits_range() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); + let commit2 = repo.find_commit(oid2).unwrap(); + let oid3 = create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); + + let commits = collect_commits(&repo, oid1, oid3).unwrap(); + assert_eq!(commits.len(), 2); + } + + #[test] + fn test_graph_id_many_branches() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + + repo.branch("b1", &commit1, false).unwrap(); + repo.branch("b2", &commit1, false).unwrap(); + repo.branch("b3", &commit1, false).unwrap(); + repo.branch("b4", &commit1, false).unwrap(); + + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: Some(oid1.to_string()), + }; + + let result = derive( + &repo, + &[ + "b1".to_string(), + "b2".to_string(), + "b3".to_string(), + "b4".to_string(), + ], + &config, + ) + .unwrap(); + + match result { + Document::Graph(g) => { + assert!(g.graph.id.contains("4-branches")); + } + _ => panic!("Expected Graph"), + } + } + + #[test] + fn test_commit_to_step_creates_actor() { + let (_dir, repo) = init_temp_repo(); + let oid = create_commit(&repo, "a commit", "file.txt", "content", None); + let commit = repo.find_commit(oid).unwrap(); + + let mut actors = HashMap::new(); + let step = commit_to_step(&repo, &commit, Oid::zero(), &mut actors).unwrap(); + + assert!(step.step.actor.starts_with("human:")); + assert!(!actors.is_empty()); + let actor_def = actors.values().next().unwrap(); + assert_eq!(actor_def.name.as_deref(), Some("Test User")); + } + + #[test] + fn test_derive_config_fields() { + let config = DeriveConfig { + remote: "origin".to_string(), + title: Some("My Graph".to_string()), + base: None, + }; + assert_eq!(config.remote, "origin"); + assert_eq!(config.title.as_deref(), Some("My Graph")); + assert!(config.base.is_none()); + } + } } +// Re-export native-only functions at crate root for API compatibility +#[cfg(not(target_os = "emscripten"))] +pub use native::{derive, derive_graph, derive_path, get_repo_uri, list_branches}; + #[cfg(test)] mod tests { use super::*; @@ -768,7 +1092,6 @@ mod tests { #[test] fn test_slugify_empty_email_username() { - // email with no @ β€” the split returns the full string, same as email assert_eq!(slugify_author("Test User", "noreply"), "test-user"); } @@ -794,356 +1117,4 @@ mod tests { assert_eq!(spec.name, "main"); assert_eq!(spec.start.as_deref(), Some("abc1234")); } - - // ── safe_prefix / short_oid ──────────────────────────────────────── - - #[test] - fn test_safe_prefix_ascii() { - assert_eq!(safe_prefix("abcdef12345", 8), "abcdef12"); - } - - #[test] - fn test_safe_prefix_short_string() { - assert_eq!(safe_prefix("abc", 8), "abc"); - } - - #[test] - fn test_safe_prefix_empty() { - assert_eq!(safe_prefix("", 8), ""); - } - - #[test] - fn test_safe_prefix_multibyte() { - // Ensure we don't panic on multi-byte chars - assert_eq!(safe_prefix("cafΓ©", 3), "caf"); - assert_eq!(safe_prefix("ζ—₯本θͺžγƒ†γ‚Ήγƒˆ", 3), "ζ—₯本θͺž"); - } - - #[test] - fn test_short_oid() { - let oid = Oid::from_str("abcdef1234567890abcdef1234567890abcdef12").unwrap(); - assert_eq!(short_oid(oid), "abcdef12"); - } - - // ── DeriveConfig default ─────────────────────────────────────────── - - #[test] - fn test_derive_config_fields() { - let config = DeriveConfig { - remote: "origin".to_string(), - title: Some("My Graph".to_string()), - base: None, - }; - assert_eq!(config.remote, "origin"); - assert_eq!(config.title.as_deref(), Some("My Graph")); - assert!(config.base.is_none()); - } - - // ── Integration tests with temp git repo ─────────────────────────── - - fn init_temp_repo() -> (tempfile::TempDir, Repository) { - let dir = tempfile::tempdir().unwrap(); - let repo = Repository::init(dir.path()).unwrap(); - - // Configure author for commits - let mut config = repo.config().unwrap(); - config.set_str("user.name", "Test User").unwrap(); - config.set_str("user.email", "test@example.com").unwrap(); - - (dir, repo) - } - - fn create_commit( - repo: &Repository, - message: &str, - file_name: &str, - content: &str, - parent: Option<&git2::Commit>, - ) -> Oid { - let mut index = repo.index().unwrap(); - let file_path = repo.workdir().unwrap().join(file_name); - std::fs::write(&file_path, content).unwrap(); - index.add_path(std::path::Path::new(file_name)).unwrap(); - index.write().unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let sig = repo.signature().unwrap(); - let parents: Vec<&git2::Commit> = parent.into_iter().collect(); - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) - .unwrap() - } - - #[test] - fn test_list_branches_on_repo() { - let (_dir, repo) = init_temp_repo(); - // Create initial commit so a branch exists - create_commit(&repo, "initial", "file.txt", "hello", None); - - let branches = list_branches(&repo).unwrap(); - assert!(!branches.is_empty()); - // Should contain "main" or "master" depending on git config - let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); - assert!( - names.contains(&"main") || names.contains(&"master"), - "Expected main or master in {:?}", - names - ); - } - - #[test] - fn test_list_branches_sorted() { - let (_dir, repo) = init_temp_repo(); - let oid = create_commit(&repo, "initial", "file.txt", "hello", None); - let commit = repo.find_commit(oid).unwrap(); - - // Create additional branches - repo.branch("b-beta", &commit, false).unwrap(); - repo.branch("a-alpha", &commit, false).unwrap(); - - let branches = list_branches(&repo).unwrap(); - let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); - // Should be sorted alphabetically - let mut sorted = names.clone(); - sorted.sort(); - assert_eq!(names, sorted); - } - - #[test] - fn test_get_repo_uri_no_remote() { - let (_dir, repo) = init_temp_repo(); - let uri = get_repo_uri(&repo, "origin").unwrap(); - assert!( - uri.starts_with("file://"), - "Expected file:// URI, got {}", - uri - ); - } - - #[test] - fn test_derive_single_branch() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); - - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: None, - }; - - // Get the default branch name - let default = find_default_branch(&repo).unwrap_or("main".to_string()); - let result = derive(&repo, &[default], &config).unwrap(); - - match result { - Document::Path(path) => { - assert!(!path.steps.is_empty(), "Expected at least one step"); - assert!(path.path.base.is_some()); - } - _ => panic!("Expected Document::Path for single branch"), - } - } - - #[test] - fn test_derive_multiple_branches_produces_graph() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let _oid2 = create_commit(&repo, "on default", "file.txt", "v2", Some(&commit1)); - - let default_branch = find_default_branch(&repo).unwrap(); - - // Create a feature branch from commit1 - repo.branch("feature", &commit1, false).unwrap(); - repo.set_head("refs/heads/feature").unwrap(); - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) - .unwrap(); - let commit1_again = repo.find_commit(oid1).unwrap(); - create_commit( - &repo, - "feature work", - "feature.txt", - "feat", - Some(&commit1_again), - ); - - // Go back to default branch - repo.set_head(&format!("refs/heads/{}", default_branch)) - .unwrap(); - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) - .unwrap(); - - let config = DeriveConfig { - remote: "origin".to_string(), - title: Some("Test Graph".to_string()), - base: None, - }; - - let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap(); - - match result { - Document::Graph(graph) => { - assert_eq!(graph.paths.len(), 2); - assert!(graph.meta.is_some()); - assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph"); - } - _ => panic!("Expected Document::Graph for multiple branches"), - } - } - - #[test] - fn test_find_default_branch() { - let (_dir, repo) = init_temp_repo(); - create_commit(&repo, "initial", "file.txt", "hello", None); - - let default = find_default_branch(&repo); - assert!(default.is_some()); - // git init creates "main" or "master" depending on git config - let name = default.unwrap(); - assert!(name == "main" || name == "master"); - } - - #[test] - fn test_branch_info_fields() { - let (_dir, repo) = init_temp_repo(); - create_commit(&repo, "test subject line", "file.txt", "hello", None); - - let branches = list_branches(&repo).unwrap(); - let branch = &branches[0]; - - assert!(!branch.head.is_empty()); - assert_eq!(branch.head_short.len(), 8); - assert_eq!(branch.subject, "test subject line"); - assert_eq!(branch.author, "Test User"); - assert!(branch.timestamp.ends_with('Z')); - } - - #[test] - fn test_derive_with_global_base() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let oid2 = create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); - let commit2 = repo.find_commit(oid2).unwrap(); - create_commit(&repo, "third commit", "file.txt", "v3", Some(&commit2)); - - let default = find_default_branch(&repo).unwrap(); - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: Some(oid1.to_string()), - }; - - let result = derive(&repo, &[default], &config).unwrap(); - match result { - Document::Path(path) => { - // Should only include commits after oid1 - assert!(path.steps.len() >= 1); - } - _ => panic!("Expected Document::Path"), - } - } - - #[test] - fn test_derive_path_with_branch_start() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); - let commit2 = repo.find_commit(oid2).unwrap(); - create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); - - let default = find_default_branch(&repo).unwrap(); - let spec = BranchSpec { - name: default, - start: Some(oid1.to_string()), - }; - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: None, - }; - - let path = derive_path(&repo, &spec, &config).unwrap(); - assert!(path.steps.len() >= 1); - } - - #[test] - fn test_generate_diff_initial_commit() { - let (_dir, repo) = init_temp_repo(); - let oid = create_commit(&repo, "initial", "file.txt", "hello world", None); - let commit = repo.find_commit(oid).unwrap(); - - let changes = generate_diff(&repo, &commit).unwrap(); - // Initial commit should have a diff for the new file - assert!(!changes.is_empty()); - assert!(changes.contains_key("file.txt")); - } - - #[test] - fn test_collect_commits_range() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); - let commit2 = repo.find_commit(oid2).unwrap(); - let oid3 = create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); - - let commits = collect_commits(&repo, oid1, oid3).unwrap(); - assert_eq!(commits.len(), 2); // second and third, not first - } - - #[test] - fn test_graph_id_many_branches() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - - // Create 4 branches - repo.branch("b1", &commit1, false).unwrap(); - repo.branch("b2", &commit1, false).unwrap(); - repo.branch("b3", &commit1, false).unwrap(); - repo.branch("b4", &commit1, false).unwrap(); - - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: Some(oid1.to_string()), - }; - - let result = derive( - &repo, - &[ - "b1".to_string(), - "b2".to_string(), - "b3".to_string(), - "b4".to_string(), - ], - &config, - ) - .unwrap(); - - match result { - Document::Graph(g) => { - assert!(g.graph.id.contains("4-branches")); - } - _ => panic!("Expected Graph"), - } - } - - #[test] - fn test_commit_to_step_creates_actor() { - let (_dir, repo) = init_temp_repo(); - let oid = create_commit(&repo, "a commit", "file.txt", "content", None); - let commit = repo.find_commit(oid).unwrap(); - - let mut actors = HashMap::new(); - let step = commit_to_step(&repo, &commit, Oid::zero(), &mut actors).unwrap(); - - assert!(step.step.actor.starts_with("human:")); - assert!(!actors.is_empty()); - let actor_def = actors.values().next().unwrap(); - assert_eq!(actor_def.name.as_deref(), Some("Test User")); - } } diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..4301dfd --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WASM_JS="$ROOT/site/wasm/path.js" +WASM_BIN="$ROOT/site/wasm/path.wasm" +EMSDK_DIR="$ROOT/local/emsdk" + +# --- Parse flags -------------------------------------------------------------- +# --if-changed Skip build if outputs are newer than all Rust sources +# --dev Use dev profile (fast incremental builds, no LTO/strip) + +DEV=false +IF_CHANGED=false +for arg in "$@"; do + case "$arg" in + --dev) DEV=true ;; + --if-changed) IF_CHANGED=true ;; + esac +done + +if $DEV; then + PROFILE=dev + PROFILE_DIR=debug + SENTINEL="$ROOT/target/.wasm-dev-built" +else + PROFILE=wasm + PROFILE_DIR=wasm + SENTINEL="$ROOT/target/.wasm-built" +fi + +# --- Staleness check ---------------------------------------------------------- + +wasm_is_stale() { + [ ! -f "$WASM_JS" ] || [ ! -f "$WASM_BIN" ] || [ ! -f "$SENTINEL" ] && return 0 + + [ -n "$(find "$ROOT/crates" "$ROOT/Cargo.toml" "$ROOT/.cargo/config.toml" \ + \( -name '*.rs' -o -name 'Cargo.toml' \) \ + -newer "$SENTINEL" 2>/dev/null | head -1)" ] +} + +if $IF_CHANGED; then + if ! wasm_is_stale; then + exit 0 + fi + echo "wasm: Rust sources changed, rebuilding ($PROFILE)..." +fi + +# --- Ensure emsdk is available ------------------------------------------------ + +ensure_emsdk() { + # Already on PATH? + if command -v emcc &>/dev/null; then + return 0 + fi + + # Local install exists? Activate it. + if [ -f "$EMSDK_DIR/emsdk_env.sh" ]; then + echo "wasm: Activating local emsdk..." + source "$EMSDK_DIR/emsdk_env.sh" 2>/dev/null + return 0 + fi + + # Bootstrap: clone + install + activate + echo "wasm: Installing emsdk to target/emsdk (one-time)..." + git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$EMSDK_DIR" + "$EMSDK_DIR/emsdk" install latest + "$EMSDK_DIR/emsdk" activate latest + source "$EMSDK_DIR/emsdk_env.sh" 2>/dev/null +} + +ensure_emsdk + +# --- Ensure rustup target ----------------------------------------------------- + +if ! rustup target list --installed 2>/dev/null | grep -q wasm32-unknown-emscripten; then + echo "wasm: Adding rustup target wasm32-unknown-emscripten..." + rustup target add wasm32-unknown-emscripten +fi + +# --- Build -------------------------------------------------------------------- + +cd "$ROOT" +cargo build --target wasm32-unknown-emscripten -p toolpath-cli --profile "$PROFILE" + +mkdir -p site/wasm +cp "target/wasm32-unknown-emscripten/$PROFILE_DIR/path.js" site/wasm/path.js +cp "target/wasm32-unknown-emscripten/$PROFILE_DIR/path.wasm" site/wasm/path.wasm +touch "$SENTINEL" + +echo "wasm: Built site/wasm/path.{js,wasm} ($PROFILE)" +ls -lh site/wasm/ diff --git a/scripts/site.sh b/scripts/site.sh index ea7e7e0..a82981a 100755 --- a/scripts/site.sh +++ b/scripts/site.sh @@ -1,11 +1,50 @@ #!/usr/bin/env bash set -euo pipefail -cd "$(dirname "$0")/../site" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WASM_SCRIPT="$ROOT/scripts/build-wasm.sh" + +# --- Wasm watcher (polls every 2s for Rust source changes) ------------------- +wasm_watch() { + local flags=("$@") + while true; do + "$WASM_SCRIPT" --if-changed "${flags[@]}" 2>&1 | while IFS= read -r line; do echo "$line"; done + sleep 2 + done +} + +# --- Wasm build (best-effort: warn but don't block if emcc missing) ---------- +wasm_build_or_warn() { + if "$WASM_SCRIPT" "$@" 2>&1; then + return 0 + else + echo "" + echo " Note: wasm build failed β€” playground will show a fallback message." + echo " Install the Emscripten SDK and re-run to enable the wasm playground." + echo "" + return 0 + fi +} + +cd "$ROOT/site" case "${1:-dev}" in - dev) pnpm run dev ;; - build) pnpm run build ;; - install) pnpm install ;; - *) echo "Usage: scripts/site.sh [dev|build|install]" >&2; exit 1 ;; + dev) + wasm_build_or_warn --dev --if-changed + wasm_watch --dev & + WASM_PID=$! + trap 'kill $WASM_PID 2>/dev/null' EXIT + pnpm run dev + ;; + build) + wasm_build_or_warn + pnpm run build + ;; + install) + pnpm install + ;; + *) + echo "Usage: scripts/site.sh [dev|build|install]" >&2 + exit 1 + ;; esac diff --git a/site/_includes/base.njk b/site/_includes/base.njk index ea6dc7f..27886ef 100644 --- a/site/_includes/base.njk +++ b/site/_includes/base.njk @@ -9,6 +9,12 @@ + {% if nav == "home" %} + + + + + {% endif %}