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