Skip to main content

toolpath_git/
lib.rs

1//! Derive Toolpath provenance documents from git repository history.
2//!
3//! This crate converts git commit history into Toolpath [`Document`]s,
4//! mapping branches to [`Path`]s and multi-branch views to [`Graph`]s.
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use git2::{Commit, DiffOptions, Oid, Repository};
9use std::collections::HashMap;
10use toolpath::v1::{
11    ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity,
12    Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource,
13};
14
15// ============================================================================
16// Public configuration and types
17// ============================================================================
18
19/// Configuration for deriving Toolpath documents from a git repository.
20pub struct DeriveConfig {
21    /// Remote name for URI generation (e.g., "origin").
22    pub remote: String,
23    /// Optional title for graph output.
24    pub title: Option<String>,
25    /// Global base commit override (overrides per-branch starts).
26    pub base: Option<String>,
27}
28
29/// Parsed branch specification.
30///
31/// Branches can be specified as `"name"` or `"name:start"` where `start` is a
32/// revision expression indicating where the path should begin.
33#[derive(Debug, Clone)]
34pub struct BranchSpec {
35    pub name: String,
36    pub start: Option<String>,
37}
38
39impl BranchSpec {
40    /// Parse a branch specification string.
41    ///
42    /// Format: `"name"` or `"name:start"`.
43    pub fn parse(s: &str) -> Self {
44        if let Some((name, start)) = s.split_once(':') {
45            BranchSpec {
46                name: name.to_string(),
47                start: Some(start.to_string()),
48            }
49        } else {
50            BranchSpec {
51                name: s.to_string(),
52                start: None,
53            }
54        }
55    }
56}
57
58// ============================================================================
59// Public API
60// ============================================================================
61
62/// Derive a Toolpath [`Document`] from the given repository and branch names.
63///
64/// Branch strings are parsed as [`BranchSpec`]s (supporting `"name:start"` syntax).
65/// A single branch produces a [`Document::Path`]; multiple branches produce a
66/// [`Document::Graph`].
67pub fn derive(repo: &Repository, branches: &[String], config: &DeriveConfig) -> Result<Document> {
68    let branch_specs: Vec<BranchSpec> = branches.iter().map(|s| BranchSpec::parse(s)).collect();
69
70    if branch_specs.len() == 1 {
71        let path_doc = derive_path(repo, &branch_specs[0], config)?;
72        Ok(Document::Path(path_doc))
73    } else {
74        let graph_doc = derive_graph(repo, &branch_specs, config)?;
75        Ok(Document::Graph(graph_doc))
76    }
77}
78
79/// Derive a Toolpath [`Path`] from a single branch specification.
80pub fn derive_path(repo: &Repository, spec: &BranchSpec, config: &DeriveConfig) -> Result<Path> {
81    let repo_uri = get_repo_uri(repo, &config.remote)?;
82
83    let branch_ref = repo
84        .find_branch(&spec.name, git2::BranchType::Local)
85        .with_context(|| format!("Branch '{}' not found", spec.name))?;
86    let branch_commit = branch_ref.get().peel_to_commit()?;
87
88    // Determine base commit
89    let base_oid = if let Some(global_base) = &config.base {
90        // Global base overrides per-branch
91        let obj = repo
92            .revparse_single(global_base)
93            .with_context(|| format!("Failed to parse base ref '{}'", global_base))?;
94        obj.peel_to_commit()?.id()
95    } else if let Some(start) = &spec.start {
96        // Per-branch start commit - resolve relative to the branch
97        // e.g., "main:HEAD~5" means 5 commits before main's HEAD
98        let start_ref = if let Some(rest) = start.strip_prefix("HEAD") {
99            // Replace HEAD with the branch name for relative refs
100            format!("{}{}", spec.name, rest)
101        } else {
102            start.clone()
103        };
104        let obj = repo.revparse_single(&start_ref).with_context(|| {
105            format!(
106                "Failed to parse start ref '{}' (resolved to '{}') for branch '{}'",
107                start, start_ref, spec.name
108            )
109        })?;
110        obj.peel_to_commit()?.id()
111    } else {
112        // Default: find merge-base with default branch
113        find_base_for_branch(repo, &branch_commit)?
114    };
115
116    let base_commit = repo.find_commit(base_oid)?;
117
118    // Collect commits from base to head
119    let commits = collect_commits(repo, base_oid, branch_commit.id())?;
120
121    // Generate steps and collect actor definitions
122    let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
123    let steps = generate_steps(repo, &commits, base_oid, &mut actors)?;
124
125    // Build path document
126    let head_step_id = if steps.is_empty() {
127        format!("step-{}", short_oid(branch_commit.id()))
128    } else {
129        steps.last().unwrap().step.id.clone()
130    };
131
132    Ok(Path {
133        path: PathIdentity {
134            id: format!("path-{}", spec.name.replace('/', "-")),
135            base: Some(Base {
136                uri: repo_uri,
137                ref_str: Some(base_commit.id().to_string()),
138            }),
139            head: head_step_id,
140        },
141        steps,
142        meta: Some(PathMeta {
143            title: Some(format!("Branch: {}", spec.name)),
144            actors: if actors.is_empty() {
145                None
146            } else {
147                Some(actors)
148            },
149            ..Default::default()
150        }),
151    })
152}
153
154/// Derive a Toolpath [`Graph`] from multiple branch specifications.
155pub fn derive_graph(
156    repo: &Repository,
157    branch_specs: &[BranchSpec],
158    config: &DeriveConfig,
159) -> Result<Graph> {
160    // Find the default branch name
161    let default_branch = find_default_branch(repo);
162
163    // If the default branch is included without an explicit start, compute the earliest
164    // merge-base among all other branches to use as its starting point
165    let default_branch_start = compute_default_branch_start(repo, branch_specs, &default_branch)?;
166
167    // Generate paths for each branch with its own base
168    let mut paths = Vec::new();
169    for spec in branch_specs {
170        // Check if this is the default branch and needs special handling
171        let effective_spec = if default_branch_start.is_some()
172            && spec.start.is_none()
173            && default_branch.as_ref() == Some(&spec.name)
174        {
175            BranchSpec {
176                name: spec.name.clone(),
177                start: default_branch_start.clone(),
178            }
179        } else {
180            spec.clone()
181        };
182        let path_doc = derive_path(repo, &effective_spec, config)?;
183        paths.push(PathOrRef::Path(Box::new(path_doc)));
184    }
185
186    // Create graph ID from branch names
187    let branch_names: Vec<&str> = branch_specs.iter().map(|s| s.name.as_str()).collect();
188    let graph_id = if branch_names.len() <= 3 {
189        format!(
190            "graph-{}",
191            branch_names
192                .iter()
193                .map(|b| b.replace('/', "-"))
194                .collect::<Vec<_>>()
195                .join("-")
196        )
197    } else {
198        format!("graph-{}-branches", branch_names.len())
199    };
200
201    let title = config
202        .title
203        .clone()
204        .unwrap_or_else(|| format!("Branches: {}", branch_names.join(", ")));
205
206    Ok(Graph {
207        graph: GraphIdentity { id: graph_id },
208        paths,
209        meta: Some(GraphMeta {
210            title: Some(title),
211            ..Default::default()
212        }),
213    })
214}
215
216// ============================================================================
217// Public utility functions
218// ============================================================================
219
220/// Get the repository URI from a remote, falling back to a file:// URI.
221pub fn get_repo_uri(repo: &Repository, remote_name: &str) -> Result<String> {
222    if let Ok(remote) = repo.find_remote(remote_name)
223        && let Some(url) = remote.url()
224    {
225        return Ok(normalize_git_url(url));
226    }
227
228    // Fall back to file path
229    if let Some(path) = repo.path().parent() {
230        return Ok(format!("file://{}", path.display()));
231    }
232
233    Ok("file://unknown".to_string())
234}
235
236/// Normalize a git remote URL to a canonical short form.
237///
238/// Converts common hosting URLs to compact identifiers:
239/// - `git@github.com:org/repo.git` -> `github:org/repo`
240/// - `https://github.com/org/repo.git` -> `github:org/repo`
241/// - `git@gitlab.com:org/repo.git` -> `gitlab:org/repo`
242/// - `https://gitlab.com/org/repo.git` -> `gitlab:org/repo`
243pub fn normalize_git_url(url: &str) -> String {
244    if let Some(rest) = url.strip_prefix("git@github.com:") {
245        let repo = rest.trim_end_matches(".git");
246        return format!("github:{}", repo);
247    }
248
249    if let Some(rest) = url.strip_prefix("https://github.com/") {
250        let repo = rest.trim_end_matches(".git");
251        return format!("github:{}", repo);
252    }
253
254    if let Some(rest) = url.strip_prefix("git@gitlab.com:") {
255        let repo = rest.trim_end_matches(".git");
256        return format!("gitlab:{}", repo);
257    }
258
259    if let Some(rest) = url.strip_prefix("https://gitlab.com/") {
260        let repo = rest.trim_end_matches(".git");
261        return format!("gitlab:{}", repo);
262    }
263
264    // Return as-is for other URLs
265    url.to_string()
266}
267
268/// Create a URL-safe slug from a git author name and email.
269///
270/// Prefers the email username; falls back to the name.
271pub fn slugify_author(name: &str, email: &str) -> String {
272    // Try to extract username from email
273    if let Some(username) = email.split('@').next()
274        && !username.is_empty()
275        && username != email
276    {
277        return username
278            .to_lowercase()
279            .chars()
280            .map(|c| if c.is_alphanumeric() { c } else { '-' })
281            .collect();
282    }
283
284    // Fall back to name
285    name.to_lowercase()
286        .chars()
287        .map(|c| if c.is_alphanumeric() { c } else { '-' })
288        .collect::<String>()
289        .trim_matches('-')
290        .to_string()
291}
292
293// ============================================================================
294// Listing / discovery
295// ============================================================================
296
297/// Summary information about a local branch.
298#[derive(Debug, Clone)]
299pub struct BranchInfo {
300    /// Branch name (e.g., "main", "feature/foo").
301    pub name: String,
302    /// Short (8-char) hex of the tip commit.
303    pub head_short: String,
304    /// Full hex OID of the tip commit.
305    pub head: String,
306    /// First line of the tip commit message.
307    pub subject: String,
308    /// Author name of the tip commit.
309    pub author: String,
310    /// ISO 8601 timestamp of the tip commit.
311    pub timestamp: String,
312}
313
314/// List local branches with summary metadata.
315pub fn list_branches(repo: &Repository) -> Result<Vec<BranchInfo>> {
316    let mut branches = Vec::new();
317
318    for branch_result in repo.branches(Some(git2::BranchType::Local))? {
319        let (branch, _) = branch_result?;
320        let name = branch.name()?.unwrap_or("<invalid utf-8>").to_string();
321
322        let commit = branch.get().peel_to_commit()?;
323
324        let author = commit.author();
325        let author_name = author.name().unwrap_or("unknown").to_string();
326
327        let time = commit.time();
328        let timestamp = DateTime::<Utc>::from_timestamp(time.seconds(), 0)
329            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
330            .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
331
332        let subject = commit
333            .message()
334            .unwrap_or("")
335            .lines()
336            .next()
337            .unwrap_or("")
338            .to_string();
339
340        branches.push(BranchInfo {
341            name,
342            head_short: short_oid(commit.id()),
343            head: commit.id().to_string(),
344            subject,
345            author: author_name,
346            timestamp,
347        });
348    }
349
350    branches.sort_by(|a, b| a.name.cmp(&b.name));
351    Ok(branches)
352}
353
354// ============================================================================
355// Private helpers
356// ============================================================================
357
358/// When the default branch is included in a multi-branch graph without an explicit start,
359/// compute the earliest merge-base among all feature branches to use as main's start.
360/// This ensures we see main's commits back to where the earliest feature diverged.
361fn compute_default_branch_start(
362    repo: &Repository,
363    branch_specs: &[BranchSpec],
364    default_branch: &Option<String>,
365) -> Result<Option<String>> {
366    let default_name = match default_branch {
367        Some(name) => name,
368        None => return Ok(None),
369    };
370
371    // Check if the default branch is in the list and doesn't have an explicit start
372    let default_in_list = branch_specs
373        .iter()
374        .any(|s| &s.name == default_name && s.start.is_none());
375    if !default_in_list {
376        return Ok(None);
377    }
378
379    // Get the default branch commit
380    let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?;
381    let default_commit = default_ref.get().peel_to_commit()?;
382
383    // Find the earliest merge-base among all non-default branches
384    let mut earliest_base: Option<Oid> = None;
385
386    for spec in branch_specs {
387        if &spec.name == default_name {
388            continue;
389        }
390
391        let branch_ref = match repo.find_branch(&spec.name, git2::BranchType::Local) {
392            Ok(r) => r,
393            Err(_) => continue,
394        };
395        let branch_commit = match branch_ref.get().peel_to_commit() {
396            Ok(c) => c,
397            Err(_) => continue,
398        };
399
400        if let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) {
401            // Check if this merge-base is earlier (ancestor of) current earliest
402            match earliest_base {
403                None => earliest_base = Some(merge_base),
404                Some(current) => {
405                    // If merge_base is an ancestor of current, use merge_base
406                    // (it's "earlier" in the commit history)
407                    if repo.merge_base(merge_base, current).ok() == Some(merge_base)
408                        && merge_base != current
409                    {
410                        earliest_base = Some(merge_base);
411                    }
412                }
413            }
414        }
415    }
416
417    // Use the GRANDPARENT of the earliest merge-base so both the merge-base and its parent
418    // are included in main's steps. This avoids showing an orphan BASE node.
419    if let Some(base_oid) = earliest_base
420        && let Ok(base_commit) = repo.find_commit(base_oid)
421        && base_commit.parent_count() > 0
422        && let Ok(parent) = base_commit.parent(0)
423    {
424        // Try to get grandparent
425        if parent.parent_count() > 0
426            && let Ok(grandparent) = parent.parent(0)
427        {
428            return Ok(Some(grandparent.id().to_string()));
429        }
430        // Fall back to parent if no grandparent
431        return Ok(Some(parent.id().to_string()));
432    }
433
434    Ok(earliest_base.map(|oid| oid.to_string()))
435}
436
437fn find_base_for_branch(repo: &Repository, branch_commit: &Commit) -> Result<Oid> {
438    // Try to find merge-base with default branch, but only if the branch
439    // being derived is *not* the default branch itself (merge-base of a
440    // branch with itself is its own tip, which yields zero commits).
441    if let Some(default_branch) = find_default_branch(repo)
442        && let Ok(default_ref) = repo.find_branch(&default_branch, git2::BranchType::Local)
443        && let Ok(default_commit) = default_ref.get().peel_to_commit()
444        && default_commit.id() != branch_commit.id()
445        && let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id())
446        && merge_base != branch_commit.id()
447    {
448        return Ok(merge_base);
449    }
450
451    // Fall back to first commit in history (root of the branch)
452    let mut walker = repo.revwalk()?;
453    walker.push(branch_commit.id())?;
454    walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
455
456    if let Some(Ok(oid)) = walker.next() {
457        return Ok(oid);
458    }
459
460    Ok(branch_commit.id())
461}
462
463fn find_default_branch(repo: &Repository) -> Option<String> {
464    // Try common default branch names
465    for name in &["main", "master", "trunk", "develop"] {
466        if repo.find_branch(name, git2::BranchType::Local).is_ok() {
467            return Some(name.to_string());
468        }
469    }
470    None
471}
472
473fn collect_commits<'a>(
474    repo: &'a Repository,
475    base_oid: Oid,
476    head_oid: Oid,
477) -> Result<Vec<Commit<'a>>> {
478    let mut walker = repo.revwalk()?;
479    walker.push(head_oid)?;
480    walker.hide(base_oid)?;
481    walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
482
483    let mut commits = Vec::new();
484    for oid_result in walker {
485        let oid = oid_result?;
486        let commit = repo.find_commit(oid)?;
487        commits.push(commit);
488    }
489
490    Ok(commits)
491}
492
493fn generate_steps(
494    repo: &Repository,
495    commits: &[Commit],
496    base_oid: Oid,
497    actors: &mut HashMap<String, ActorDefinition>,
498) -> Result<Vec<Step>> {
499    let mut steps = Vec::new();
500
501    for commit in commits {
502        let step = commit_to_step(repo, commit, base_oid, actors)?;
503        steps.push(step);
504    }
505
506    Ok(steps)
507}
508
509fn commit_to_step(
510    repo: &Repository,
511    commit: &Commit,
512    base_oid: Oid,
513    actors: &mut HashMap<String, ActorDefinition>,
514) -> Result<Step> {
515    let step_id = format!("step-{}", short_oid(commit.id()));
516
517    // Filter parents to only include those that aren't the base commit
518    let parents: Vec<String> = commit
519        .parent_ids()
520        .filter(|pid| *pid != base_oid)
521        .map(|pid| format!("step-{}", short_oid(pid)))
522        .collect();
523
524    // Get author info
525    let author = commit.author();
526    let author_name = author.name().unwrap_or("unknown");
527    let author_email = author.email().unwrap_or("unknown");
528    let actor = format!("human:{}", slugify_author(author_name, author_email));
529
530    // Register actor definition
531    actors.entry(actor.clone()).or_insert_with(|| {
532        let mut identities = Vec::new();
533        if author_email != "unknown" {
534            identities.push(Identity {
535                system: "email".to_string(),
536                id: author_email.to_string(),
537            });
538        }
539        ActorDefinition {
540            name: Some(author_name.to_string()),
541            identities,
542            ..Default::default()
543        }
544    });
545
546    // Get timestamp
547    let time = commit.time();
548    let timestamp = DateTime::<Utc>::from_timestamp(time.seconds(), 0)
549        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
550        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
551
552    // Generate diff
553    let change = generate_diff(repo, commit)?;
554
555    // Get commit message as intent
556    let message = commit.message().unwrap_or("").trim();
557    let intent = if message.is_empty() {
558        None
559    } else {
560        // Use first line of commit message
561        Some(message.lines().next().unwrap_or(message).to_string())
562    };
563
564    // VCS source reference
565    let source = VcsSource {
566        vcs_type: "git".to_string(),
567        revision: commit.id().to_string(),
568        change_id: None,
569    };
570
571    Ok(Step {
572        step: StepIdentity {
573            id: step_id,
574            parents,
575            actor,
576            timestamp,
577        },
578        change,
579        meta: Some(StepMeta {
580            intent,
581            source: Some(source),
582            ..Default::default()
583        }),
584    })
585}
586
587fn generate_diff(repo: &Repository, commit: &Commit) -> Result<HashMap<String, ArtifactChange>> {
588    let tree = commit.tree()?;
589
590    let parent_tree = if commit.parent_count() > 0 {
591        Some(commit.parent(0)?.tree()?)
592    } else {
593        None
594    };
595
596    let mut diff_opts = DiffOptions::new();
597    diff_opts.context_lines(3);
598
599    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
600
601    let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
602    let mut current_file: Option<String> = None;
603    let mut current_diff = String::new();
604
605    diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
606        let file_path = delta
607            .new_file()
608            .path()
609            .or_else(|| delta.old_file().path())
610            .map(|p| p.to_string_lossy().to_string());
611
612        if let Some(path) = file_path {
613            // Check if we're starting a new file
614            if current_file.as_ref() != Some(&path) {
615                // Save previous file's diff
616                if let Some(prev_file) = current_file.take()
617                    && !current_diff.is_empty()
618                {
619                    changes.insert(prev_file, ArtifactChange::raw(&current_diff));
620                }
621                current_file = Some(path);
622                current_diff.clear();
623            }
624        }
625
626        // Append line to current diff
627        let prefix = match line.origin() {
628            '+' => "+",
629            '-' => "-",
630            ' ' => " ",
631            '>' => ">",
632            '<' => "<",
633            'F' => "",  // File header
634            'H' => "@", // Hunk header - we'll handle this specially
635            'B' => "",
636            _ => "",
637        };
638
639        if line.origin() == 'H' {
640            // Hunk header
641            if let Ok(content) = std::str::from_utf8(line.content()) {
642                current_diff.push_str("@@");
643                current_diff.push_str(content.trim_start_matches('@'));
644            }
645        } else if (!prefix.is_empty() || line.origin() == ' ')
646            && let Ok(content) = std::str::from_utf8(line.content())
647        {
648            current_diff.push_str(prefix);
649            current_diff.push_str(content);
650        }
651
652        true
653    })?;
654
655    // Don't forget the last file
656    if let Some(file) = current_file
657        && !current_diff.is_empty()
658    {
659        changes.insert(file, ArtifactChange::raw(&current_diff));
660    }
661
662    Ok(changes)
663}
664
665fn short_oid(oid: Oid) -> String {
666    safe_prefix(&oid.to_string(), 8)
667}
668
669/// Return the first `n` characters of a string, safe for any UTF-8 content.
670fn safe_prefix(s: &str, n: usize) -> String {
671    s.chars().take(n).collect()
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    // ── normalize_git_url ──────────────────────────────────────────────
679
680    #[test]
681    fn test_normalize_github_ssh() {
682        assert_eq!(
683            normalize_git_url("git@github.com:org/repo.git"),
684            "github:org/repo"
685        );
686    }
687
688    #[test]
689    fn test_normalize_github_https() {
690        assert_eq!(
691            normalize_git_url("https://github.com/org/repo.git"),
692            "github:org/repo"
693        );
694    }
695
696    #[test]
697    fn test_normalize_github_https_no_suffix() {
698        assert_eq!(
699            normalize_git_url("https://github.com/org/repo"),
700            "github:org/repo"
701        );
702    }
703
704    #[test]
705    fn test_normalize_gitlab_ssh() {
706        assert_eq!(
707            normalize_git_url("git@gitlab.com:org/repo.git"),
708            "gitlab:org/repo"
709        );
710    }
711
712    #[test]
713    fn test_normalize_gitlab_https() {
714        assert_eq!(
715            normalize_git_url("https://gitlab.com/org/repo.git"),
716            "gitlab:org/repo"
717        );
718    }
719
720    #[test]
721    fn test_normalize_unknown_url_passthrough() {
722        let url = "https://bitbucket.org/org/repo.git";
723        assert_eq!(normalize_git_url(url), url);
724    }
725
726    // ── slugify_author ─────────────────────────────────────────────────
727
728    #[test]
729    fn test_slugify_prefers_email_username() {
730        assert_eq!(slugify_author("Alex Smith", "asmith@example.com"), "asmith");
731    }
732
733    #[test]
734    fn test_slugify_falls_back_to_name() {
735        assert_eq!(slugify_author("Alex Smith", "unknown"), "alex-smith");
736    }
737
738    #[test]
739    fn test_slugify_lowercases() {
740        assert_eq!(slugify_author("Alex", "Alex@example.com"), "alex");
741    }
742
743    #[test]
744    fn test_slugify_replaces_special_chars() {
745        assert_eq!(slugify_author("A.B", "a.b@example.com"), "a-b");
746    }
747
748    #[test]
749    fn test_slugify_empty_email_username() {
750        // email with no @ — the split returns the full string, same as email
751        assert_eq!(slugify_author("Test User", "noreply"), "test-user");
752    }
753
754    // ── BranchSpec::parse ──────────────────────────────────────────────
755
756    #[test]
757    fn test_branch_spec_simple() {
758        let spec = BranchSpec::parse("main");
759        assert_eq!(spec.name, "main");
760        assert!(spec.start.is_none());
761    }
762
763    #[test]
764    fn test_branch_spec_with_start() {
765        let spec = BranchSpec::parse("feature:HEAD~5");
766        assert_eq!(spec.name, "feature");
767        assert_eq!(spec.start.as_deref(), Some("HEAD~5"));
768    }
769
770    #[test]
771    fn test_branch_spec_with_commit_start() {
772        let spec = BranchSpec::parse("main:abc1234");
773        assert_eq!(spec.name, "main");
774        assert_eq!(spec.start.as_deref(), Some("abc1234"));
775    }
776
777    // ── safe_prefix / short_oid ────────────────────────────────────────
778
779    #[test]
780    fn test_safe_prefix_ascii() {
781        assert_eq!(safe_prefix("abcdef12345", 8), "abcdef12");
782    }
783
784    #[test]
785    fn test_safe_prefix_short_string() {
786        assert_eq!(safe_prefix("abc", 8), "abc");
787    }
788
789    #[test]
790    fn test_safe_prefix_empty() {
791        assert_eq!(safe_prefix("", 8), "");
792    }
793
794    #[test]
795    fn test_safe_prefix_multibyte() {
796        // Ensure we don't panic on multi-byte chars
797        assert_eq!(safe_prefix("café", 3), "caf");
798        assert_eq!(safe_prefix("日本語テスト", 3), "日本語");
799    }
800
801    #[test]
802    fn test_short_oid() {
803        let oid = Oid::from_str("abcdef1234567890abcdef1234567890abcdef12").unwrap();
804        assert_eq!(short_oid(oid), "abcdef12");
805    }
806
807    // ── DeriveConfig default ───────────────────────────────────────────
808
809    #[test]
810    fn test_derive_config_fields() {
811        let config = DeriveConfig {
812            remote: "origin".to_string(),
813            title: Some("My Graph".to_string()),
814            base: None,
815        };
816        assert_eq!(config.remote, "origin");
817        assert_eq!(config.title.as_deref(), Some("My Graph"));
818        assert!(config.base.is_none());
819    }
820
821    // ── Integration tests with temp git repo ───────────────────────────
822
823    fn init_temp_repo() -> (tempfile::TempDir, Repository) {
824        let dir = tempfile::tempdir().unwrap();
825        let repo = Repository::init(dir.path()).unwrap();
826
827        // Configure author for commits
828        let mut config = repo.config().unwrap();
829        config.set_str("user.name", "Test User").unwrap();
830        config.set_str("user.email", "test@example.com").unwrap();
831
832        (dir, repo)
833    }
834
835    fn create_commit(
836        repo: &Repository,
837        message: &str,
838        file_name: &str,
839        content: &str,
840        parent: Option<&git2::Commit>,
841    ) -> Oid {
842        let mut index = repo.index().unwrap();
843        let file_path = repo.workdir().unwrap().join(file_name);
844        std::fs::write(&file_path, content).unwrap();
845        index.add_path(std::path::Path::new(file_name)).unwrap();
846        index.write().unwrap();
847        let tree_id = index.write_tree().unwrap();
848        let tree = repo.find_tree(tree_id).unwrap();
849        let sig = repo.signature().unwrap();
850        let parents: Vec<&git2::Commit> = parent.into_iter().collect();
851        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
852            .unwrap()
853    }
854
855    #[test]
856    fn test_list_branches_on_repo() {
857        let (_dir, repo) = init_temp_repo();
858        // Create initial commit so a branch exists
859        create_commit(&repo, "initial", "file.txt", "hello", None);
860
861        let branches = list_branches(&repo).unwrap();
862        assert!(!branches.is_empty());
863        // Should contain "main" or "master" depending on git config
864        let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
865        assert!(
866            names.contains(&"main") || names.contains(&"master"),
867            "Expected main or master in {:?}",
868            names
869        );
870    }
871
872    #[test]
873    fn test_list_branches_sorted() {
874        let (_dir, repo) = init_temp_repo();
875        let oid = create_commit(&repo, "initial", "file.txt", "hello", None);
876        let commit = repo.find_commit(oid).unwrap();
877
878        // Create additional branches
879        repo.branch("b-beta", &commit, false).unwrap();
880        repo.branch("a-alpha", &commit, false).unwrap();
881
882        let branches = list_branches(&repo).unwrap();
883        let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
884        // Should be sorted alphabetically
885        let mut sorted = names.clone();
886        sorted.sort();
887        assert_eq!(names, sorted);
888    }
889
890    #[test]
891    fn test_get_repo_uri_no_remote() {
892        let (_dir, repo) = init_temp_repo();
893        let uri = get_repo_uri(&repo, "origin").unwrap();
894        assert!(
895            uri.starts_with("file://"),
896            "Expected file:// URI, got {}",
897            uri
898        );
899    }
900
901    #[test]
902    fn test_derive_single_branch() {
903        let (_dir, repo) = init_temp_repo();
904        let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None);
905        let commit1 = repo.find_commit(oid1).unwrap();
906        create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1));
907
908        let config = DeriveConfig {
909            remote: "origin".to_string(),
910            title: None,
911            base: None,
912        };
913
914        // Get the default branch name
915        let default = find_default_branch(&repo).unwrap_or("main".to_string());
916        let result = derive(&repo, &[default], &config).unwrap();
917
918        match result {
919            Document::Path(path) => {
920                assert!(!path.steps.is_empty(), "Expected at least one step");
921                assert!(path.path.base.is_some());
922            }
923            _ => panic!("Expected Document::Path for single branch"),
924        }
925    }
926
927    #[test]
928    fn test_derive_multiple_branches_produces_graph() {
929        let (_dir, repo) = init_temp_repo();
930        let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None);
931        let commit1 = repo.find_commit(oid1).unwrap();
932        let _oid2 = create_commit(&repo, "on default", "file.txt", "v2", Some(&commit1));
933
934        let default_branch = find_default_branch(&repo).unwrap();
935
936        // Create a feature branch from commit1
937        repo.branch("feature", &commit1, false).unwrap();
938        repo.set_head("refs/heads/feature").unwrap();
939        repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
940            .unwrap();
941        let commit1_again = repo.find_commit(oid1).unwrap();
942        create_commit(
943            &repo,
944            "feature work",
945            "feature.txt",
946            "feat",
947            Some(&commit1_again),
948        );
949
950        // Go back to default branch
951        repo.set_head(&format!("refs/heads/{}", default_branch))
952            .unwrap();
953        repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
954            .unwrap();
955
956        let config = DeriveConfig {
957            remote: "origin".to_string(),
958            title: Some("Test Graph".to_string()),
959            base: None,
960        };
961
962        let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap();
963
964        match result {
965            Document::Graph(graph) => {
966                assert_eq!(graph.paths.len(), 2);
967                assert!(graph.meta.is_some());
968                assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph");
969            }
970            _ => panic!("Expected Document::Graph for multiple branches"),
971        }
972    }
973
974    #[test]
975    fn test_find_default_branch() {
976        let (_dir, repo) = init_temp_repo();
977        create_commit(&repo, "initial", "file.txt", "hello", None);
978
979        let default = find_default_branch(&repo);
980        assert!(default.is_some());
981        // git init creates "main" or "master" depending on git config
982        let name = default.unwrap();
983        assert!(name == "main" || name == "master");
984    }
985
986    #[test]
987    fn test_branch_info_fields() {
988        let (_dir, repo) = init_temp_repo();
989        create_commit(&repo, "test subject line", "file.txt", "hello", None);
990
991        let branches = list_branches(&repo).unwrap();
992        let branch = &branches[0];
993
994        assert!(!branch.head.is_empty());
995        assert_eq!(branch.head_short.len(), 8);
996        assert_eq!(branch.subject, "test subject line");
997        assert_eq!(branch.author, "Test User");
998        assert!(branch.timestamp.ends_with('Z'));
999    }
1000
1001    #[test]
1002    fn test_derive_with_global_base() {
1003        let (_dir, repo) = init_temp_repo();
1004        let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None);
1005        let commit1 = repo.find_commit(oid1).unwrap();
1006        let oid2 = create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1));
1007        let commit2 = repo.find_commit(oid2).unwrap();
1008        create_commit(&repo, "third commit", "file.txt", "v3", Some(&commit2));
1009
1010        let default = find_default_branch(&repo).unwrap();
1011        let config = DeriveConfig {
1012            remote: "origin".to_string(),
1013            title: None,
1014            base: Some(oid1.to_string()),
1015        };
1016
1017        let result = derive(&repo, &[default], &config).unwrap();
1018        match result {
1019            Document::Path(path) => {
1020                // Should only include commits after oid1
1021                assert!(path.steps.len() >= 1);
1022            }
1023            _ => panic!("Expected Document::Path"),
1024        }
1025    }
1026
1027    #[test]
1028    fn test_derive_path_with_branch_start() {
1029        let (_dir, repo) = init_temp_repo();
1030        let oid1 = create_commit(&repo, "first", "file.txt", "v1", None);
1031        let commit1 = repo.find_commit(oid1).unwrap();
1032        let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1));
1033        let commit2 = repo.find_commit(oid2).unwrap();
1034        create_commit(&repo, "third", "file.txt", "v3", Some(&commit2));
1035
1036        let default = find_default_branch(&repo).unwrap();
1037        let spec = BranchSpec {
1038            name: default,
1039            start: Some(oid1.to_string()),
1040        };
1041        let config = DeriveConfig {
1042            remote: "origin".to_string(),
1043            title: None,
1044            base: None,
1045        };
1046
1047        let path = derive_path(&repo, &spec, &config).unwrap();
1048        assert!(path.steps.len() >= 1);
1049    }
1050
1051    #[test]
1052    fn test_generate_diff_initial_commit() {
1053        let (_dir, repo) = init_temp_repo();
1054        let oid = create_commit(&repo, "initial", "file.txt", "hello world", None);
1055        let commit = repo.find_commit(oid).unwrap();
1056
1057        let changes = generate_diff(&repo, &commit).unwrap();
1058        // Initial commit should have a diff for the new file
1059        assert!(!changes.is_empty());
1060        assert!(changes.contains_key("file.txt"));
1061    }
1062
1063    #[test]
1064    fn test_collect_commits_range() {
1065        let (_dir, repo) = init_temp_repo();
1066        let oid1 = create_commit(&repo, "first", "file.txt", "v1", None);
1067        let commit1 = repo.find_commit(oid1).unwrap();
1068        let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1));
1069        let commit2 = repo.find_commit(oid2).unwrap();
1070        let oid3 = create_commit(&repo, "third", "file.txt", "v3", Some(&commit2));
1071
1072        let commits = collect_commits(&repo, oid1, oid3).unwrap();
1073        assert_eq!(commits.len(), 2); // second and third, not first
1074    }
1075
1076    #[test]
1077    fn test_graph_id_many_branches() {
1078        let (_dir, repo) = init_temp_repo();
1079        let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None);
1080        let commit1 = repo.find_commit(oid1).unwrap();
1081
1082        // Create 4 branches
1083        repo.branch("b1", &commit1, false).unwrap();
1084        repo.branch("b2", &commit1, false).unwrap();
1085        repo.branch("b3", &commit1, false).unwrap();
1086        repo.branch("b4", &commit1, false).unwrap();
1087
1088        let config = DeriveConfig {
1089            remote: "origin".to_string(),
1090            title: None,
1091            base: Some(oid1.to_string()),
1092        };
1093
1094        let result = derive(
1095            &repo,
1096            &[
1097                "b1".to_string(),
1098                "b2".to_string(),
1099                "b3".to_string(),
1100                "b4".to_string(),
1101            ],
1102            &config,
1103        )
1104        .unwrap();
1105
1106        match result {
1107            Document::Graph(g) => {
1108                assert!(g.graph.id.contains("4-branches"));
1109            }
1110            _ => panic!("Expected Graph"),
1111        }
1112    }
1113
1114    #[test]
1115    fn test_commit_to_step_creates_actor() {
1116        let (_dir, repo) = init_temp_repo();
1117        let oid = create_commit(&repo, "a commit", "file.txt", "content", None);
1118        let commit = repo.find_commit(oid).unwrap();
1119
1120        let mut actors = HashMap::new();
1121        let step = commit_to_step(&repo, &commit, Oid::zero(), &mut actors).unwrap();
1122
1123        assert!(step.step.actor.starts_with("human:"));
1124        assert!(!actors.is_empty());
1125        let actor_def = actors.values().next().unwrap();
1126        assert_eq!(actor_def.name.as_deref(), Some("Test User"));
1127    }
1128}