1#![doc = include_str!("../README.md")]
2
3use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use git2::{Commit, DiffOptions, Oid, Repository};
6use std::collections::HashMap;
7use toolpath::v1::{
8 ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity,
9 Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource,
10};
11
12pub struct DeriveConfig {
18 pub remote: String,
20 pub title: Option<String>,
22 pub base: Option<String>,
24}
25
26#[derive(Debug, Clone)]
31pub struct BranchSpec {
32 pub name: String,
33 pub start: Option<String>,
34}
35
36impl BranchSpec {
37 pub fn parse(s: &str) -> Self {
41 if let Some((name, start)) = s.split_once(':') {
42 BranchSpec {
43 name: name.to_string(),
44 start: Some(start.to_string()),
45 }
46 } else {
47 BranchSpec {
48 name: s.to_string(),
49 start: None,
50 }
51 }
52 }
53}
54
55pub fn derive(repo: &Repository, branches: &[String], config: &DeriveConfig) -> Result<Document> {
65 let branch_specs: Vec<BranchSpec> = branches.iter().map(|s| BranchSpec::parse(s)).collect();
66
67 if branch_specs.len() == 1 {
68 let path_doc = derive_path(repo, &branch_specs[0], config)?;
69 Ok(Document::Path(path_doc))
70 } else {
71 let graph_doc = derive_graph(repo, &branch_specs, config)?;
72 Ok(Document::Graph(graph_doc))
73 }
74}
75
76pub fn derive_path(repo: &Repository, spec: &BranchSpec, config: &DeriveConfig) -> Result<Path> {
78 let repo_uri = get_repo_uri(repo, &config.remote)?;
79
80 let branch_ref = repo
81 .find_branch(&spec.name, git2::BranchType::Local)
82 .with_context(|| format!("Branch '{}' not found", spec.name))?;
83 let branch_commit = branch_ref.get().peel_to_commit()?;
84
85 let base_oid = if let Some(global_base) = &config.base {
87 let obj = repo
89 .revparse_single(global_base)
90 .with_context(|| format!("Failed to parse base ref '{}'", global_base))?;
91 obj.peel_to_commit()?.id()
92 } else if let Some(start) = &spec.start {
93 let start_ref = if let Some(rest) = start.strip_prefix("HEAD") {
96 format!("{}{}", spec.name, rest)
98 } else {
99 start.clone()
100 };
101 let obj = repo.revparse_single(&start_ref).with_context(|| {
102 format!(
103 "Failed to parse start ref '{}' (resolved to '{}') for branch '{}'",
104 start, start_ref, spec.name
105 )
106 })?;
107 obj.peel_to_commit()?.id()
108 } else {
109 find_base_for_branch(repo, &branch_commit)?
111 };
112
113 let base_commit = repo.find_commit(base_oid)?;
114
115 let commits = collect_commits(repo, base_oid, branch_commit.id())?;
117
118 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
120 let steps = generate_steps(repo, &commits, base_oid, &mut actors)?;
121
122 let head_step_id = if steps.is_empty() {
124 format!("step-{}", short_oid(branch_commit.id()))
125 } else {
126 steps.last().unwrap().step.id.clone()
127 };
128
129 Ok(Path {
130 path: PathIdentity {
131 id: format!("path-{}", spec.name.replace('/', "-")),
132 base: Some(Base {
133 uri: repo_uri,
134 ref_str: Some(base_commit.id().to_string()),
135 }),
136 head: head_step_id,
137 },
138 steps,
139 meta: Some(PathMeta {
140 title: Some(format!("Branch: {}", spec.name)),
141 actors: if actors.is_empty() {
142 None
143 } else {
144 Some(actors)
145 },
146 ..Default::default()
147 }),
148 })
149}
150
151pub fn derive_graph(
153 repo: &Repository,
154 branch_specs: &[BranchSpec],
155 config: &DeriveConfig,
156) -> Result<Graph> {
157 let default_branch = find_default_branch(repo);
159
160 let default_branch_start = compute_default_branch_start(repo, branch_specs, &default_branch)?;
163
164 let mut paths = Vec::new();
166 for spec in branch_specs {
167 let effective_spec = if default_branch_start.is_some()
169 && spec.start.is_none()
170 && default_branch.as_ref() == Some(&spec.name)
171 {
172 BranchSpec {
173 name: spec.name.clone(),
174 start: default_branch_start.clone(),
175 }
176 } else {
177 spec.clone()
178 };
179 let path_doc = derive_path(repo, &effective_spec, config)?;
180 paths.push(PathOrRef::Path(Box::new(path_doc)));
181 }
182
183 let branch_names: Vec<&str> = branch_specs.iter().map(|s| s.name.as_str()).collect();
185 let graph_id = if branch_names.len() <= 3 {
186 format!(
187 "graph-{}",
188 branch_names
189 .iter()
190 .map(|b| b.replace('/', "-"))
191 .collect::<Vec<_>>()
192 .join("-")
193 )
194 } else {
195 format!("graph-{}-branches", branch_names.len())
196 };
197
198 let title = config
199 .title
200 .clone()
201 .unwrap_or_else(|| format!("Branches: {}", branch_names.join(", ")));
202
203 Ok(Graph {
204 graph: GraphIdentity { id: graph_id },
205 paths,
206 meta: Some(GraphMeta {
207 title: Some(title),
208 ..Default::default()
209 }),
210 })
211}
212
213pub fn get_repo_uri(repo: &Repository, remote_name: &str) -> Result<String> {
219 if let Ok(remote) = repo.find_remote(remote_name)
220 && let Some(url) = remote.url()
221 {
222 return Ok(normalize_git_url(url));
223 }
224
225 if let Some(path) = repo.path().parent() {
227 return Ok(format!("file://{}", path.display()));
228 }
229
230 Ok("file://unknown".to_string())
231}
232
233pub fn normalize_git_url(url: &str) -> String {
256 if let Some(rest) = url.strip_prefix("git@github.com:") {
257 let repo = rest.trim_end_matches(".git");
258 return format!("github:{}", repo);
259 }
260
261 if let Some(rest) = url.strip_prefix("https://github.com/") {
262 let repo = rest.trim_end_matches(".git");
263 return format!("github:{}", repo);
264 }
265
266 if let Some(rest) = url.strip_prefix("git@gitlab.com:") {
267 let repo = rest.trim_end_matches(".git");
268 return format!("gitlab:{}", repo);
269 }
270
271 if let Some(rest) = url.strip_prefix("https://gitlab.com/") {
272 let repo = rest.trim_end_matches(".git");
273 return format!("gitlab:{}", repo);
274 }
275
276 url.to_string()
278}
279
280pub fn slugify_author(name: &str, email: &str) -> String {
293 if let Some(username) = email.split('@').next()
295 && !username.is_empty()
296 && username != email
297 {
298 return username
299 .to_lowercase()
300 .chars()
301 .map(|c| if c.is_alphanumeric() { c } else { '-' })
302 .collect();
303 }
304
305 name.to_lowercase()
307 .chars()
308 .map(|c| if c.is_alphanumeric() { c } else { '-' })
309 .collect::<String>()
310 .trim_matches('-')
311 .to_string()
312}
313
314#[derive(Debug, Clone)]
320pub struct BranchInfo {
321 pub name: String,
323 pub head_short: String,
325 pub head: String,
327 pub subject: String,
329 pub author: String,
331 pub timestamp: String,
333}
334
335pub fn list_branches(repo: &Repository) -> Result<Vec<BranchInfo>> {
337 let mut branches = Vec::new();
338
339 for branch_result in repo.branches(Some(git2::BranchType::Local))? {
340 let (branch, _) = branch_result?;
341 let name = branch.name()?.unwrap_or("<invalid utf-8>").to_string();
342
343 let commit = branch.get().peel_to_commit()?;
344
345 let author = commit.author();
346 let author_name = author.name().unwrap_or("unknown").to_string();
347
348 let time = commit.time();
349 let timestamp = DateTime::<Utc>::from_timestamp(time.seconds(), 0)
350 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
351 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
352
353 let subject = commit
354 .message()
355 .unwrap_or("")
356 .lines()
357 .next()
358 .unwrap_or("")
359 .to_string();
360
361 branches.push(BranchInfo {
362 name,
363 head_short: short_oid(commit.id()),
364 head: commit.id().to_string(),
365 subject,
366 author: author_name,
367 timestamp,
368 });
369 }
370
371 branches.sort_by(|a, b| a.name.cmp(&b.name));
372 Ok(branches)
373}
374
375fn compute_default_branch_start(
383 repo: &Repository,
384 branch_specs: &[BranchSpec],
385 default_branch: &Option<String>,
386) -> Result<Option<String>> {
387 let default_name = match default_branch {
388 Some(name) => name,
389 None => return Ok(None),
390 };
391
392 let default_in_list = branch_specs
394 .iter()
395 .any(|s| &s.name == default_name && s.start.is_none());
396 if !default_in_list {
397 return Ok(None);
398 }
399
400 let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?;
402 let default_commit = default_ref.get().peel_to_commit()?;
403
404 let mut earliest_base: Option<Oid> = None;
406
407 for spec in branch_specs {
408 if &spec.name == default_name {
409 continue;
410 }
411
412 let branch_ref = match repo.find_branch(&spec.name, git2::BranchType::Local) {
413 Ok(r) => r,
414 Err(_) => continue,
415 };
416 let branch_commit = match branch_ref.get().peel_to_commit() {
417 Ok(c) => c,
418 Err(_) => continue,
419 };
420
421 if let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) {
422 match earliest_base {
424 None => earliest_base = Some(merge_base),
425 Some(current) => {
426 if repo.merge_base(merge_base, current).ok() == Some(merge_base)
429 && merge_base != current
430 {
431 earliest_base = Some(merge_base);
432 }
433 }
434 }
435 }
436 }
437
438 if let Some(base_oid) = earliest_base
441 && let Ok(base_commit) = repo.find_commit(base_oid)
442 && base_commit.parent_count() > 0
443 && let Ok(parent) = base_commit.parent(0)
444 {
445 if parent.parent_count() > 0
447 && let Ok(grandparent) = parent.parent(0)
448 {
449 return Ok(Some(grandparent.id().to_string()));
450 }
451 return Ok(Some(parent.id().to_string()));
453 }
454
455 Ok(earliest_base.map(|oid| oid.to_string()))
456}
457
458fn find_base_for_branch(repo: &Repository, branch_commit: &Commit) -> Result<Oid> {
459 if let Some(default_branch) = find_default_branch(repo)
463 && let Ok(default_ref) = repo.find_branch(&default_branch, git2::BranchType::Local)
464 && let Ok(default_commit) = default_ref.get().peel_to_commit()
465 && default_commit.id() != branch_commit.id()
466 && let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id())
467 && merge_base != branch_commit.id()
468 {
469 return Ok(merge_base);
470 }
471
472 let mut walker = repo.revwalk()?;
474 walker.push(branch_commit.id())?;
475 walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
476
477 if let Some(Ok(oid)) = walker.next() {
478 return Ok(oid);
479 }
480
481 Ok(branch_commit.id())
482}
483
484fn find_default_branch(repo: &Repository) -> Option<String> {
485 for name in &["main", "master", "trunk", "develop"] {
487 if repo.find_branch(name, git2::BranchType::Local).is_ok() {
488 return Some(name.to_string());
489 }
490 }
491 None
492}
493
494fn collect_commits<'a>(
495 repo: &'a Repository,
496 base_oid: Oid,
497 head_oid: Oid,
498) -> Result<Vec<Commit<'a>>> {
499 let mut walker = repo.revwalk()?;
500 walker.push(head_oid)?;
501 walker.hide(base_oid)?;
502 walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
503
504 let mut commits = Vec::new();
505 for oid_result in walker {
506 let oid = oid_result?;
507 let commit = repo.find_commit(oid)?;
508 commits.push(commit);
509 }
510
511 Ok(commits)
512}
513
514fn generate_steps(
515 repo: &Repository,
516 commits: &[Commit],
517 base_oid: Oid,
518 actors: &mut HashMap<String, ActorDefinition>,
519) -> Result<Vec<Step>> {
520 let mut steps = Vec::new();
521
522 for commit in commits {
523 let step = commit_to_step(repo, commit, base_oid, actors)?;
524 steps.push(step);
525 }
526
527 Ok(steps)
528}
529
530fn commit_to_step(
531 repo: &Repository,
532 commit: &Commit,
533 base_oid: Oid,
534 actors: &mut HashMap<String, ActorDefinition>,
535) -> Result<Step> {
536 let step_id = format!("step-{}", short_oid(commit.id()));
537
538 let parents: Vec<String> = commit
540 .parent_ids()
541 .filter(|pid| *pid != base_oid)
542 .map(|pid| format!("step-{}", short_oid(pid)))
543 .collect();
544
545 let author = commit.author();
547 let author_name = author.name().unwrap_or("unknown");
548 let author_email = author.email().unwrap_or("unknown");
549 let actor = format!("human:{}", slugify_author(author_name, author_email));
550
551 actors.entry(actor.clone()).or_insert_with(|| {
553 let mut identities = Vec::new();
554 if author_email != "unknown" {
555 identities.push(Identity {
556 system: "email".to_string(),
557 id: author_email.to_string(),
558 });
559 }
560 ActorDefinition {
561 name: Some(author_name.to_string()),
562 identities,
563 ..Default::default()
564 }
565 });
566
567 let time = commit.time();
569 let timestamp = DateTime::<Utc>::from_timestamp(time.seconds(), 0)
570 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
571 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
572
573 let change = generate_diff(repo, commit)?;
575
576 let message = commit.message().unwrap_or("").trim();
578 let intent = if message.is_empty() {
579 None
580 } else {
581 Some(message.lines().next().unwrap_or(message).to_string())
583 };
584
585 let source = VcsSource {
587 vcs_type: "git".to_string(),
588 revision: commit.id().to_string(),
589 change_id: None,
590 };
591
592 Ok(Step {
593 step: StepIdentity {
594 id: step_id,
595 parents,
596 actor,
597 timestamp,
598 },
599 change,
600 meta: Some(StepMeta {
601 intent,
602 source: Some(source),
603 ..Default::default()
604 }),
605 })
606}
607
608fn generate_diff(repo: &Repository, commit: &Commit) -> Result<HashMap<String, ArtifactChange>> {
609 let tree = commit.tree()?;
610
611 let parent_tree = if commit.parent_count() > 0 {
612 Some(commit.parent(0)?.tree()?)
613 } else {
614 None
615 };
616
617 let mut diff_opts = DiffOptions::new();
618 diff_opts.context_lines(3);
619
620 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
621
622 let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
623 let mut current_file: Option<String> = None;
624 let mut current_diff = String::new();
625
626 diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
627 let file_path = delta
628 .new_file()
629 .path()
630 .or_else(|| delta.old_file().path())
631 .map(|p| p.to_string_lossy().to_string());
632
633 if let Some(path) = file_path {
634 if current_file.as_ref() != Some(&path) {
636 if let Some(prev_file) = current_file.take()
638 && !current_diff.is_empty()
639 {
640 changes.insert(prev_file, ArtifactChange::raw(¤t_diff));
641 }
642 current_file = Some(path);
643 current_diff.clear();
644 }
645 }
646
647 let prefix = match line.origin() {
649 '+' => "+",
650 '-' => "-",
651 ' ' => " ",
652 '>' => ">",
653 '<' => "<",
654 'F' => "", 'H' => "@", 'B' => "",
657 _ => "",
658 };
659
660 if line.origin() == 'H' {
661 if let Ok(content) = std::str::from_utf8(line.content()) {
663 current_diff.push_str("@@");
664 current_diff.push_str(content.trim_start_matches('@'));
665 }
666 } else if (!prefix.is_empty() || line.origin() == ' ')
667 && let Ok(content) = std::str::from_utf8(line.content())
668 {
669 current_diff.push_str(prefix);
670 current_diff.push_str(content);
671 }
672
673 true
674 })?;
675
676 if let Some(file) = current_file
678 && !current_diff.is_empty()
679 {
680 changes.insert(file, ArtifactChange::raw(¤t_diff));
681 }
682
683 Ok(changes)
684}
685
686fn short_oid(oid: Oid) -> String {
687 safe_prefix(&oid.to_string(), 8)
688}
689
690fn safe_prefix(s: &str, n: usize) -> String {
692 s.chars().take(n).collect()
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 #[test]
702 fn test_normalize_github_ssh() {
703 assert_eq!(
704 normalize_git_url("git@github.com:org/repo.git"),
705 "github:org/repo"
706 );
707 }
708
709 #[test]
710 fn test_normalize_github_https() {
711 assert_eq!(
712 normalize_git_url("https://github.com/org/repo.git"),
713 "github:org/repo"
714 );
715 }
716
717 #[test]
718 fn test_normalize_github_https_no_suffix() {
719 assert_eq!(
720 normalize_git_url("https://github.com/org/repo"),
721 "github:org/repo"
722 );
723 }
724
725 #[test]
726 fn test_normalize_gitlab_ssh() {
727 assert_eq!(
728 normalize_git_url("git@gitlab.com:org/repo.git"),
729 "gitlab:org/repo"
730 );
731 }
732
733 #[test]
734 fn test_normalize_gitlab_https() {
735 assert_eq!(
736 normalize_git_url("https://gitlab.com/org/repo.git"),
737 "gitlab:org/repo"
738 );
739 }
740
741 #[test]
742 fn test_normalize_unknown_url_passthrough() {
743 let url = "https://bitbucket.org/org/repo.git";
744 assert_eq!(normalize_git_url(url), url);
745 }
746
747 #[test]
750 fn test_slugify_prefers_email_username() {
751 assert_eq!(slugify_author("Alex Smith", "asmith@example.com"), "asmith");
752 }
753
754 #[test]
755 fn test_slugify_falls_back_to_name() {
756 assert_eq!(slugify_author("Alex Smith", "unknown"), "alex-smith");
757 }
758
759 #[test]
760 fn test_slugify_lowercases() {
761 assert_eq!(slugify_author("Alex", "Alex@example.com"), "alex");
762 }
763
764 #[test]
765 fn test_slugify_replaces_special_chars() {
766 assert_eq!(slugify_author("A.B", "a.b@example.com"), "a-b");
767 }
768
769 #[test]
770 fn test_slugify_empty_email_username() {
771 assert_eq!(slugify_author("Test User", "noreply"), "test-user");
773 }
774
775 #[test]
778 fn test_branch_spec_simple() {
779 let spec = BranchSpec::parse("main");
780 assert_eq!(spec.name, "main");
781 assert!(spec.start.is_none());
782 }
783
784 #[test]
785 fn test_branch_spec_with_start() {
786 let spec = BranchSpec::parse("feature:HEAD~5");
787 assert_eq!(spec.name, "feature");
788 assert_eq!(spec.start.as_deref(), Some("HEAD~5"));
789 }
790
791 #[test]
792 fn test_branch_spec_with_commit_start() {
793 let spec = BranchSpec::parse("main:abc1234");
794 assert_eq!(spec.name, "main");
795 assert_eq!(spec.start.as_deref(), Some("abc1234"));
796 }
797
798 #[test]
801 fn test_safe_prefix_ascii() {
802 assert_eq!(safe_prefix("abcdef12345", 8), "abcdef12");
803 }
804
805 #[test]
806 fn test_safe_prefix_short_string() {
807 assert_eq!(safe_prefix("abc", 8), "abc");
808 }
809
810 #[test]
811 fn test_safe_prefix_empty() {
812 assert_eq!(safe_prefix("", 8), "");
813 }
814
815 #[test]
816 fn test_safe_prefix_multibyte() {
817 assert_eq!(safe_prefix("café", 3), "caf");
819 assert_eq!(safe_prefix("日本語テスト", 3), "日本語");
820 }
821
822 #[test]
823 fn test_short_oid() {
824 let oid = Oid::from_str("abcdef1234567890abcdef1234567890abcdef12").unwrap();
825 assert_eq!(short_oid(oid), "abcdef12");
826 }
827
828 #[test]
831 fn test_derive_config_fields() {
832 let config = DeriveConfig {
833 remote: "origin".to_string(),
834 title: Some("My Graph".to_string()),
835 base: None,
836 };
837 assert_eq!(config.remote, "origin");
838 assert_eq!(config.title.as_deref(), Some("My Graph"));
839 assert!(config.base.is_none());
840 }
841
842 fn init_temp_repo() -> (tempfile::TempDir, Repository) {
845 let dir = tempfile::tempdir().unwrap();
846 let repo = Repository::init(dir.path()).unwrap();
847
848 let mut config = repo.config().unwrap();
850 config.set_str("user.name", "Test User").unwrap();
851 config.set_str("user.email", "test@example.com").unwrap();
852
853 (dir, repo)
854 }
855
856 fn create_commit(
857 repo: &Repository,
858 message: &str,
859 file_name: &str,
860 content: &str,
861 parent: Option<&git2::Commit>,
862 ) -> Oid {
863 let mut index = repo.index().unwrap();
864 let file_path = repo.workdir().unwrap().join(file_name);
865 std::fs::write(&file_path, content).unwrap();
866 index.add_path(std::path::Path::new(file_name)).unwrap();
867 index.write().unwrap();
868 let tree_id = index.write_tree().unwrap();
869 let tree = repo.find_tree(tree_id).unwrap();
870 let sig = repo.signature().unwrap();
871 let parents: Vec<&git2::Commit> = parent.into_iter().collect();
872 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
873 .unwrap()
874 }
875
876 #[test]
877 fn test_list_branches_on_repo() {
878 let (_dir, repo) = init_temp_repo();
879 create_commit(&repo, "initial", "file.txt", "hello", None);
881
882 let branches = list_branches(&repo).unwrap();
883 assert!(!branches.is_empty());
884 let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
886 assert!(
887 names.contains(&"main") || names.contains(&"master"),
888 "Expected main or master in {:?}",
889 names
890 );
891 }
892
893 #[test]
894 fn test_list_branches_sorted() {
895 let (_dir, repo) = init_temp_repo();
896 let oid = create_commit(&repo, "initial", "file.txt", "hello", None);
897 let commit = repo.find_commit(oid).unwrap();
898
899 repo.branch("b-beta", &commit, false).unwrap();
901 repo.branch("a-alpha", &commit, false).unwrap();
902
903 let branches = list_branches(&repo).unwrap();
904 let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
905 let mut sorted = names.clone();
907 sorted.sort();
908 assert_eq!(names, sorted);
909 }
910
911 #[test]
912 fn test_get_repo_uri_no_remote() {
913 let (_dir, repo) = init_temp_repo();
914 let uri = get_repo_uri(&repo, "origin").unwrap();
915 assert!(
916 uri.starts_with("file://"),
917 "Expected file:// URI, got {}",
918 uri
919 );
920 }
921
922 #[test]
923 fn test_derive_single_branch() {
924 let (_dir, repo) = init_temp_repo();
925 let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None);
926 let commit1 = repo.find_commit(oid1).unwrap();
927 create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1));
928
929 let config = DeriveConfig {
930 remote: "origin".to_string(),
931 title: None,
932 base: None,
933 };
934
935 let default = find_default_branch(&repo).unwrap_or("main".to_string());
937 let result = derive(&repo, &[default], &config).unwrap();
938
939 match result {
940 Document::Path(path) => {
941 assert!(!path.steps.is_empty(), "Expected at least one step");
942 assert!(path.path.base.is_some());
943 }
944 _ => panic!("Expected Document::Path for single branch"),
945 }
946 }
947
948 #[test]
949 fn test_derive_multiple_branches_produces_graph() {
950 let (_dir, repo) = init_temp_repo();
951 let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None);
952 let commit1 = repo.find_commit(oid1).unwrap();
953 let _oid2 = create_commit(&repo, "on default", "file.txt", "v2", Some(&commit1));
954
955 let default_branch = find_default_branch(&repo).unwrap();
956
957 repo.branch("feature", &commit1, false).unwrap();
959 repo.set_head("refs/heads/feature").unwrap();
960 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
961 .unwrap();
962 let commit1_again = repo.find_commit(oid1).unwrap();
963 create_commit(
964 &repo,
965 "feature work",
966 "feature.txt",
967 "feat",
968 Some(&commit1_again),
969 );
970
971 repo.set_head(&format!("refs/heads/{}", default_branch))
973 .unwrap();
974 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
975 .unwrap();
976
977 let config = DeriveConfig {
978 remote: "origin".to_string(),
979 title: Some("Test Graph".to_string()),
980 base: None,
981 };
982
983 let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap();
984
985 match result {
986 Document::Graph(graph) => {
987 assert_eq!(graph.paths.len(), 2);
988 assert!(graph.meta.is_some());
989 assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph");
990 }
991 _ => panic!("Expected Document::Graph for multiple branches"),
992 }
993 }
994
995 #[test]
996 fn test_find_default_branch() {
997 let (_dir, repo) = init_temp_repo();
998 create_commit(&repo, "initial", "file.txt", "hello", None);
999
1000 let default = find_default_branch(&repo);
1001 assert!(default.is_some());
1002 let name = default.unwrap();
1004 assert!(name == "main" || name == "master");
1005 }
1006
1007 #[test]
1008 fn test_branch_info_fields() {
1009 let (_dir, repo) = init_temp_repo();
1010 create_commit(&repo, "test subject line", "file.txt", "hello", None);
1011
1012 let branches = list_branches(&repo).unwrap();
1013 let branch = &branches[0];
1014
1015 assert!(!branch.head.is_empty());
1016 assert_eq!(branch.head_short.len(), 8);
1017 assert_eq!(branch.subject, "test subject line");
1018 assert_eq!(branch.author, "Test User");
1019 assert!(branch.timestamp.ends_with('Z'));
1020 }
1021
1022 #[test]
1023 fn test_derive_with_global_base() {
1024 let (_dir, repo) = init_temp_repo();
1025 let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None);
1026 let commit1 = repo.find_commit(oid1).unwrap();
1027 let oid2 = create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1));
1028 let commit2 = repo.find_commit(oid2).unwrap();
1029 create_commit(&repo, "third commit", "file.txt", "v3", Some(&commit2));
1030
1031 let default = find_default_branch(&repo).unwrap();
1032 let config = DeriveConfig {
1033 remote: "origin".to_string(),
1034 title: None,
1035 base: Some(oid1.to_string()),
1036 };
1037
1038 let result = derive(&repo, &[default], &config).unwrap();
1039 match result {
1040 Document::Path(path) => {
1041 assert!(path.steps.len() >= 1);
1043 }
1044 _ => panic!("Expected Document::Path"),
1045 }
1046 }
1047
1048 #[test]
1049 fn test_derive_path_with_branch_start() {
1050 let (_dir, repo) = init_temp_repo();
1051 let oid1 = create_commit(&repo, "first", "file.txt", "v1", None);
1052 let commit1 = repo.find_commit(oid1).unwrap();
1053 let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1));
1054 let commit2 = repo.find_commit(oid2).unwrap();
1055 create_commit(&repo, "third", "file.txt", "v3", Some(&commit2));
1056
1057 let default = find_default_branch(&repo).unwrap();
1058 let spec = BranchSpec {
1059 name: default,
1060 start: Some(oid1.to_string()),
1061 };
1062 let config = DeriveConfig {
1063 remote: "origin".to_string(),
1064 title: None,
1065 base: None,
1066 };
1067
1068 let path = derive_path(&repo, &spec, &config).unwrap();
1069 assert!(path.steps.len() >= 1);
1070 }
1071
1072 #[test]
1073 fn test_generate_diff_initial_commit() {
1074 let (_dir, repo) = init_temp_repo();
1075 let oid = create_commit(&repo, "initial", "file.txt", "hello world", None);
1076 let commit = repo.find_commit(oid).unwrap();
1077
1078 let changes = generate_diff(&repo, &commit).unwrap();
1079 assert!(!changes.is_empty());
1081 assert!(changes.contains_key("file.txt"));
1082 }
1083
1084 #[test]
1085 fn test_collect_commits_range() {
1086 let (_dir, repo) = init_temp_repo();
1087 let oid1 = create_commit(&repo, "first", "file.txt", "v1", None);
1088 let commit1 = repo.find_commit(oid1).unwrap();
1089 let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1));
1090 let commit2 = repo.find_commit(oid2).unwrap();
1091 let oid3 = create_commit(&repo, "third", "file.txt", "v3", Some(&commit2));
1092
1093 let commits = collect_commits(&repo, oid1, oid3).unwrap();
1094 assert_eq!(commits.len(), 2); }
1096
1097 #[test]
1098 fn test_graph_id_many_branches() {
1099 let (_dir, repo) = init_temp_repo();
1100 let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None);
1101 let commit1 = repo.find_commit(oid1).unwrap();
1102
1103 repo.branch("b1", &commit1, false).unwrap();
1105 repo.branch("b2", &commit1, false).unwrap();
1106 repo.branch("b3", &commit1, false).unwrap();
1107 repo.branch("b4", &commit1, false).unwrap();
1108
1109 let config = DeriveConfig {
1110 remote: "origin".to_string(),
1111 title: None,
1112 base: Some(oid1.to_string()),
1113 };
1114
1115 let result = derive(
1116 &repo,
1117 &[
1118 "b1".to_string(),
1119 "b2".to_string(),
1120 "b3".to_string(),
1121 "b4".to_string(),
1122 ],
1123 &config,
1124 )
1125 .unwrap();
1126
1127 match result {
1128 Document::Graph(g) => {
1129 assert!(g.graph.id.contains("4-branches"));
1130 }
1131 _ => panic!("Expected Graph"),
1132 }
1133 }
1134
1135 #[test]
1136 fn test_commit_to_step_creates_actor() {
1137 let (_dir, repo) = init_temp_repo();
1138 let oid = create_commit(&repo, "a commit", "file.txt", "content", None);
1139 let commit = repo.find_commit(oid).unwrap();
1140
1141 let mut actors = HashMap::new();
1142 let step = commit_to_step(&repo, &commit, Oid::zero(), &mut actors).unwrap();
1143
1144 assert!(step.step.actor.starts_with("human:"));
1145 assert!(!actors.is_empty());
1146 let actor_def = actors.values().next().unwrap();
1147 assert_eq!(actor_def.name.as_deref(), Some("Test User"));
1148 }
1149}