1use 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
15pub struct DeriveConfig {
21 pub remote: String,
23 pub title: Option<String>,
25 pub base: Option<String>,
27}
28
29#[derive(Debug, Clone)]
34pub struct BranchSpec {
35 pub name: String,
36 pub start: Option<String>,
37}
38
39impl BranchSpec {
40 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
58pub 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
79pub 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 let base_oid = if let Some(global_base) = &config.base {
90 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 let start_ref = if let Some(rest) = start.strip_prefix("HEAD") {
99 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 find_base_for_branch(repo, &branch_commit)?
114 };
115
116 let base_commit = repo.find_commit(base_oid)?;
117
118 let commits = collect_commits(repo, base_oid, branch_commit.id())?;
120
121 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
123 let steps = generate_steps(repo, &commits, base_oid, &mut actors)?;
124
125 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
154pub fn derive_graph(
156 repo: &Repository,
157 branch_specs: &[BranchSpec],
158 config: &DeriveConfig,
159) -> Result<Graph> {
160 let default_branch = find_default_branch(repo);
162
163 let default_branch_start = compute_default_branch_start(repo, branch_specs, &default_branch)?;
166
167 let mut paths = Vec::new();
169 for spec in branch_specs {
170 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 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
216pub 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 if let Some(path) = repo.path().parent() {
230 return Ok(format!("file://{}", path.display()));
231 }
232
233 Ok("file://unknown".to_string())
234}
235
236pub 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 url.to_string()
266}
267
268pub fn slugify_author(name: &str, email: &str) -> String {
272 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 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#[derive(Debug, Clone)]
299pub struct BranchInfo {
300 pub name: String,
302 pub head_short: String,
304 pub head: String,
306 pub subject: String,
308 pub author: String,
310 pub timestamp: String,
312}
313
314pub 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
354fn 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 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 let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?;
381 let default_commit = default_ref.get().peel_to_commit()?;
382
383 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 match earliest_base {
403 None => earliest_base = Some(merge_base),
404 Some(current) => {
405 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 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 if parent.parent_count() > 0
426 && let Ok(grandparent) = parent.parent(0)
427 {
428 return Ok(Some(grandparent.id().to_string()));
429 }
430 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 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 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 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 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 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 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 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 let change = generate_diff(repo, commit)?;
554
555 let message = commit.message().unwrap_or("").trim();
557 let intent = if message.is_empty() {
558 None
559 } else {
560 Some(message.lines().next().unwrap_or(message).to_string())
562 };
563
564 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 if current_file.as_ref() != Some(&path) {
615 if let Some(prev_file) = current_file.take()
617 && !current_diff.is_empty()
618 {
619 changes.insert(prev_file, ArtifactChange::raw(¤t_diff));
620 }
621 current_file = Some(path);
622 current_diff.clear();
623 }
624 }
625
626 let prefix = match line.origin() {
628 '+' => "+",
629 '-' => "-",
630 ' ' => " ",
631 '>' => ">",
632 '<' => "<",
633 'F' => "", 'H' => "@", 'B' => "",
636 _ => "",
637 };
638
639 if line.origin() == 'H' {
640 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 if let Some(file) = current_file
657 && !current_diff.is_empty()
658 {
659 changes.insert(file, ArtifactChange::raw(¤t_diff));
660 }
661
662 Ok(changes)
663}
664
665fn short_oid(oid: Oid) -> String {
666 safe_prefix(&oid.to_string(), 8)
667}
668
669fn 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 #[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 #[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 assert_eq!(slugify_author("Test User", "noreply"), "test-user");
752 }
753
754 #[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 #[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 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 #[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 fn init_temp_repo() -> (tempfile::TempDir, Repository) {
824 let dir = tempfile::tempdir().unwrap();
825 let repo = Repository::init(dir.path()).unwrap();
826
827 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_commit(&repo, "initial", "file.txt", "hello", None);
860
861 let branches = list_branches(&repo).unwrap();
862 assert!(!branches.is_empty());
863 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 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 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 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 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 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 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 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 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); }
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 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}