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