git_graph/
graph.rs

1//! A graph structure representing the history of a Git repository.
2
3use crate::print::colors::to_terminal_color;
4use crate::settings::{BranchOrder, BranchSettings, MergePatterns, Settings};
5use git2::{BranchType, Commit, Error, Oid, Reference, Repository};
6use itertools::Itertools;
7use regex::Regex;
8use std::collections::{HashMap, HashSet};
9
10const ORIGIN: &str = "origin/";
11const FORK: &str = "fork/";
12
13/// Represents a git history graph.
14pub struct GitGraph {
15    pub repository: Repository,
16    pub commits: Vec<CommitInfo>,
17    /// Mapping from commit id to index in `commits`
18    pub indices: HashMap<Oid, usize>,
19    /// All detected branches and tags, including merged and deleted
20    pub all_branches: Vec<BranchInfo>,
21    /// Indices of all real (still existing) branches in `all_branches`
22    pub branches: Vec<usize>,
23    /// Indices of all tags in `all_branches`
24    pub tags: Vec<usize>,
25    /// The current HEAD
26    pub head: HeadInfo,
27}
28
29impl GitGraph {
30    pub fn new(
31        mut repository: Repository,
32        settings: &Settings,
33        max_count: Option<usize>,
34    ) -> Result<Self, String> {
35        let mut stashes = HashSet::new();
36        repository
37            .stash_foreach(|_, _, oid| {
38                stashes.insert(*oid);
39                true
40            })
41            .map_err(|err| err.message().to_string())?;
42
43        let mut walk = repository
44            .revwalk()
45            .map_err(|err| err.message().to_string())?;
46
47        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
48            .map_err(|err| err.message().to_string())?;
49
50        walk.push_glob("*")
51            .map_err(|err| err.message().to_string())?;
52
53        if repository.is_shallow() {
54            return Err("ERROR: git-graph does not support shallow clones due to a missing feature in the underlying libgit2 library.".to_string());
55        }
56
57        let head = HeadInfo::new(&repository.head().map_err(|err| err.message().to_string())?)?;
58
59        let mut commits = Vec::new();
60        let mut indices = HashMap::new();
61        let mut idx = 0;
62        for oid in walk {
63            if let Some(max) = max_count {
64                if idx >= max {
65                    break;
66                }
67            }
68            if let Ok(oid) = oid {
69                if !stashes.contains(&oid) {
70                    let commit = repository.find_commit(oid).unwrap();
71
72                    commits.push(CommitInfo::new(&commit));
73                    indices.insert(oid, idx);
74                    idx += 1;
75                }
76            }
77        }
78
79        assign_children(&mut commits, &indices);
80
81        let mut all_branches = assign_branches(&repository, &mut commits, &indices, settings)?;
82        correct_fork_merges(&commits, &indices, &mut all_branches, settings)?;
83        assign_sources_targets(&commits, &indices, &mut all_branches);
84
85        let (shortest_first, forward) = match settings.branch_order {
86            BranchOrder::ShortestFirst(fwd) => (true, fwd),
87            BranchOrder::LongestFirst(fwd) => (false, fwd),
88        };
89
90        assign_branch_columns(
91            &commits,
92            &indices,
93            &mut all_branches,
94            &settings.branches,
95            shortest_first,
96            forward,
97        );
98
99        let filtered_commits: Vec<CommitInfo> = commits
100            .into_iter()
101            .filter(|info| info.branch_trace.is_some())
102            .collect();
103
104        let filtered_indices: HashMap<Oid, usize> = filtered_commits
105            .iter()
106            .enumerate()
107            .map(|(idx, info)| (info.oid, idx))
108            .collect();
109
110        let index_map: HashMap<usize, Option<&usize>> = indices
111            .iter()
112            .map(|(oid, index)| (*index, filtered_indices.get(oid)))
113            .collect();
114
115        for branch in all_branches.iter_mut() {
116            if let Some(mut start_idx) = branch.range.0 {
117                let mut idx0 = index_map[&start_idx];
118                while idx0.is_none() {
119                    start_idx += 1;
120                    idx0 = index_map[&start_idx];
121                }
122                branch.range.0 = Some(*idx0.unwrap());
123            }
124            if let Some(mut end_idx) = branch.range.1 {
125                let mut idx0 = index_map[&end_idx];
126                while idx0.is_none() {
127                    end_idx -= 1;
128                    idx0 = index_map[&end_idx];
129                }
130                branch.range.1 = Some(*idx0.unwrap());
131            }
132        }
133
134        let branches = all_branches
135            .iter()
136            .enumerate()
137            .filter_map(|(idx, br)| {
138                if !br.is_merged && !br.is_tag {
139                    Some(idx)
140                } else {
141                    None
142                }
143            })
144            .collect();
145
146        let tags = all_branches
147            .iter()
148            .enumerate()
149            .filter_map(|(idx, br)| {
150                if !br.is_merged && br.is_tag {
151                    Some(idx)
152                } else {
153                    None
154                }
155            })
156            .collect();
157
158        Ok(GitGraph {
159            repository,
160            commits: filtered_commits,
161            indices: filtered_indices,
162            all_branches,
163            branches,
164            tags,
165            head,
166        })
167    }
168
169    pub fn take_repository(self) -> Repository {
170        self.repository
171    }
172
173    pub fn commit(&self, id: Oid) -> Result<Commit, Error> {
174        self.repository.find_commit(id)
175    }
176}
177
178/// Information about the current HEAD
179pub struct HeadInfo {
180    pub oid: Oid,
181    pub name: String,
182    pub is_branch: bool,
183}
184impl HeadInfo {
185    fn new(head: &Reference) -> Result<Self, String> {
186        let name = head.name().ok_or_else(|| "No name for HEAD".to_string())?;
187        let name = if name == "HEAD" {
188            name.to_string()
189        } else {
190            name[11..].to_string()
191        };
192
193        let h = HeadInfo {
194            oid: head.target().ok_or_else(|| "No id for HEAD".to_string())?,
195            name,
196            is_branch: head.is_branch(),
197        };
198        Ok(h)
199    }
200}
201
202/// Represents a commit.
203pub struct CommitInfo {
204    pub oid: Oid,
205    pub is_merge: bool,
206    pub parents: [Option<Oid>; 2],
207    pub children: Vec<Oid>,
208    pub branches: Vec<usize>,
209    pub tags: Vec<usize>,
210    pub branch_trace: Option<usize>,
211}
212
213impl CommitInfo {
214    fn new(commit: &Commit) -> Self {
215        CommitInfo {
216            oid: commit.id(),
217            is_merge: commit.parent_count() > 1,
218            parents: [commit.parent_id(0).ok(), commit.parent_id(1).ok()],
219            children: Vec::new(),
220            branches: Vec::new(),
221            tags: Vec::new(),
222            branch_trace: None,
223        }
224    }
225}
226
227/// Represents a branch (real or derived from merge summary).
228pub struct BranchInfo {
229    pub target: Oid,
230    pub merge_target: Option<Oid>,
231    pub source_branch: Option<usize>,
232    pub target_branch: Option<usize>,
233    pub name: String,
234    pub persistence: u8,
235    pub is_remote: bool,
236    pub is_merged: bool,
237    pub is_tag: bool,
238    pub visual: BranchVis,
239    pub range: (Option<usize>, Option<usize>),
240}
241impl BranchInfo {
242    #[allow(clippy::too_many_arguments)]
243    fn new(
244        target: Oid,
245        merge_target: Option<Oid>,
246        name: String,
247        persistence: u8,
248        is_remote: bool,
249        is_merged: bool,
250        is_tag: bool,
251        visual: BranchVis,
252        end_index: Option<usize>,
253    ) -> Self {
254        BranchInfo {
255            target,
256            merge_target,
257            target_branch: None,
258            source_branch: None,
259            name,
260            persistence,
261            is_remote,
262            is_merged,
263            is_tag,
264            visual,
265            range: (end_index, None),
266        }
267    }
268}
269
270/// Branch properties for visualization.
271pub struct BranchVis {
272    /// The branch's column group (left to right)
273    pub order_group: usize,
274    /// The branch's merge target column group (left to right)
275    pub target_order_group: Option<usize>,
276    /// The branch's source branch column group (left to right)
277    pub source_order_group: Option<usize>,
278    /// The branch's terminal color (index in 256-color palette)
279    pub term_color: u8,
280    /// SVG color (name or RGB in hex annotation)
281    pub svg_color: String,
282    /// The column the branch is located in
283    pub column: Option<usize>,
284}
285
286impl BranchVis {
287    fn new(order_group: usize, term_color: u8, svg_color: String) -> Self {
288        BranchVis {
289            order_group,
290            target_order_group: None,
291            source_order_group: None,
292            term_color,
293            svg_color,
294            column: None,
295        }
296    }
297}
298
299/// Walks through the commits and adds each commit's Oid to the children of its parents.
300fn assign_children(commits: &mut [CommitInfo], indices: &HashMap<Oid, usize>) {
301    for idx in 0..commits.len() {
302        let (oid, parents) = {
303            let info = &commits[idx];
304            (info.oid, info.parents)
305        };
306        for par_oid in &parents {
307            if let Some(par_idx) = par_oid.and_then(|oid| indices.get(&oid)) {
308                commits[*par_idx].children.push(oid);
309            }
310        }
311    }
312}
313
314/// Extracts branches from repository and merge summaries, assigns branches and branch traces to commits.
315///
316/// Algorithm:
317/// * Find all actual branches (incl. target oid) and all extract branches from merge summaries (incl. parent oid)
318/// * Sort all branches by persistence
319/// * Iterating over all branches in persistence order, trace back over commit parents until a trace is already assigned
320fn assign_branches(
321    repository: &Repository,
322    commits: &mut [CommitInfo],
323    indices: &HashMap<Oid, usize>,
324    settings: &Settings,
325) -> Result<Vec<BranchInfo>, String> {
326    let mut branch_idx = 0;
327
328    let mut branches = extract_branches(repository, commits, indices, settings)?;
329
330    let mut index_map: Vec<_> = (0..branches.len())
331        .map(|old_idx| {
332            let (target, is_tag, is_merged) = {
333                let branch = &branches[old_idx];
334                (branch.target, branch.is_tag, branch.is_merged)
335            };
336            if let Some(&idx) = &indices.get(&target) {
337                let info = &mut commits[idx];
338                if is_tag {
339                    info.tags.push(old_idx);
340                } else if !is_merged {
341                    info.branches.push(old_idx);
342                }
343                let oid = info.oid;
344                let any_assigned =
345                    trace_branch(repository, commits, indices, &mut branches, oid, old_idx)
346                        .unwrap_or(false);
347
348                if any_assigned || !is_merged {
349                    branch_idx += 1;
350                    Some(branch_idx - 1)
351                } else {
352                    None
353                }
354            } else {
355                None
356            }
357        })
358        .collect();
359
360    let mut commit_count = vec![0; branches.len()];
361    for info in commits.iter_mut() {
362        if let Some(trace) = info.branch_trace {
363            commit_count[trace] += 1;
364        }
365    }
366
367    let mut count_skipped = 0;
368    for (idx, branch) in branches.iter().enumerate() {
369        if let Some(mapped) = index_map[idx] {
370            if commit_count[idx] == 0 && branch.is_merged && !branch.is_tag {
371                index_map[idx] = None;
372                count_skipped += 1;
373            } else {
374                index_map[idx] = Some(mapped - count_skipped);
375            }
376        }
377    }
378
379    for info in commits.iter_mut() {
380        if let Some(trace) = info.branch_trace {
381            info.branch_trace = index_map[trace];
382            for br in info.branches.iter_mut() {
383                *br = index_map[*br].unwrap();
384            }
385            for tag in info.tags.iter_mut() {
386                *tag = index_map[*tag].unwrap();
387            }
388        }
389    }
390
391    let branches: Vec<_> = branches
392        .into_iter()
393        .enumerate()
394        .filter_map(|(arr_index, branch)| {
395            if index_map[arr_index].is_some() {
396                Some(branch)
397            } else {
398                None
399            }
400        })
401        .collect();
402
403    Ok(branches)
404}
405
406fn correct_fork_merges(
407    commits: &[CommitInfo],
408    indices: &HashMap<Oid, usize>,
409    branches: &mut [BranchInfo],
410    settings: &Settings,
411) -> Result<(), String> {
412    for idx in 0..branches.len() {
413        if let Some(merge_target) = branches[idx]
414            .merge_target
415            .and_then(|oid| indices.get(&oid))
416            .and_then(|idx| commits.get(*idx))
417            .and_then(|info| info.branch_trace)
418            .and_then(|trace| branches.get(trace))
419        {
420            if branches[idx].name == merge_target.name {
421                let name = format!("{}{}", FORK, branches[idx].name);
422                let term_col = to_terminal_color(
423                    &branch_color(
424                        &name,
425                        &settings.branches.terminal_colors[..],
426                        &settings.branches.terminal_colors_unknown,
427                        idx,
428                    )[..],
429                )?;
430                let pos = branch_order(&name, &settings.branches.order);
431                let svg_col = branch_color(
432                    &name,
433                    &settings.branches.svg_colors,
434                    &settings.branches.svg_colors_unknown,
435                    idx,
436                );
437
438                branches[idx].name = format!("{}{}", FORK, branches[idx].name);
439                branches[idx].visual.order_group = pos;
440                branches[idx].visual.term_color = term_col;
441                branches[idx].visual.svg_color = svg_col;
442            }
443        }
444    }
445    Ok(())
446}
447fn assign_sources_targets(
448    commits: &[CommitInfo],
449    indices: &HashMap<Oid, usize>,
450    branches: &mut [BranchInfo],
451) {
452    for idx in 0..branches.len() {
453        let target_branch_idx = branches[idx]
454            .merge_target
455            .and_then(|oid| indices.get(&oid))
456            .and_then(|idx| commits.get(*idx))
457            .and_then(|info| info.branch_trace);
458
459        branches[idx].target_branch = target_branch_idx;
460
461        let group = target_branch_idx
462            .and_then(|trace| branches.get(trace))
463            .map(|br| br.visual.order_group);
464
465        branches[idx].visual.target_order_group = group;
466    }
467    for info in commits {
468        let mut max_par_order = None;
469        let mut source_branch_id = None;
470        for par_oid in info.parents.iter() {
471            let par_info = par_oid
472                .and_then(|oid| indices.get(&oid))
473                .and_then(|idx| commits.get(*idx));
474            if let Some(par_info) = par_info {
475                if par_info.branch_trace != info.branch_trace {
476                    if let Some(trace) = par_info.branch_trace {
477                        source_branch_id = Some(trace);
478                    }
479
480                    let group = par_info
481                        .branch_trace
482                        .and_then(|trace| branches.get(trace))
483                        .map(|br| br.visual.order_group);
484                    if let Some(gr) = max_par_order {
485                        if let Some(p_group) = group {
486                            if p_group > gr {
487                                max_par_order = group;
488                            }
489                        }
490                    } else {
491                        max_par_order = group;
492                    }
493                }
494            }
495        }
496        let branch = info.branch_trace.and_then(|trace| branches.get_mut(trace));
497        if let Some(branch) = branch {
498            if let Some(order) = max_par_order {
499                branch.visual.source_order_group = Some(order);
500            }
501            if let Some(source_id) = source_branch_id {
502                branch.source_branch = Some(source_id);
503            }
504        }
505    }
506}
507
508/// Extracts (real or derived from merge summary) and assigns basic properties.
509fn extract_branches(
510    repository: &Repository,
511    commits: &[CommitInfo],
512    indices: &HashMap<Oid, usize>,
513    settings: &Settings,
514) -> Result<Vec<BranchInfo>, String> {
515    let filter = if settings.include_remote {
516        None
517    } else {
518        Some(BranchType::Local)
519    };
520    let actual_branches = repository
521        .branches(filter)
522        .map_err(|err| err.message().to_string())?
523        .collect::<Result<Vec<_>, Error>>()
524        .map_err(|err| err.message().to_string())?;
525
526    let mut counter = 0;
527
528    let mut valid_branches = actual_branches
529        .iter()
530        .filter_map(|(br, tp)| {
531            br.get().name().and_then(|n| {
532                br.get().target().map(|t| {
533                    counter += 1;
534                    let start_index = match tp {
535                        BranchType::Local => 11,
536                        BranchType::Remote => 13,
537                    };
538                    let name = &n[start_index..];
539                    let end_index = indices.get(&t).cloned();
540
541                    let term_color = match to_terminal_color(
542                        &branch_color(
543                            name,
544                            &settings.branches.terminal_colors[..],
545                            &settings.branches.terminal_colors_unknown,
546                            counter,
547                        )[..],
548                    ) {
549                        Ok(col) => col,
550                        Err(err) => return Err(err),
551                    };
552
553                    Ok(BranchInfo::new(
554                        t,
555                        None,
556                        name.to_string(),
557                        branch_order(name, &settings.branches.persistence) as u8,
558                        &BranchType::Remote == tp,
559                        false,
560                        false,
561                        BranchVis::new(
562                            branch_order(name, &settings.branches.order),
563                            term_color,
564                            branch_color(
565                                name,
566                                &settings.branches.svg_colors,
567                                &settings.branches.svg_colors_unknown,
568                                counter,
569                            ),
570                        ),
571                        end_index,
572                    ))
573                })
574            })
575        })
576        .collect::<Result<Vec<_>, String>>()?;
577
578    for (idx, info) in commits.iter().enumerate() {
579        let commit = repository
580            .find_commit(info.oid)
581            .map_err(|err| err.message().to_string())?;
582        if info.is_merge {
583            if let Some(summary) = commit.summary() {
584                counter += 1;
585
586                let parent_oid = commit
587                    .parent_id(1)
588                    .map_err(|err| err.message().to_string())?;
589
590                let branch_name = parse_merge_summary(summary, &settings.merge_patterns)
591                    .unwrap_or_else(|| "unknown".to_string());
592
593                let persistence = branch_order(&branch_name, &settings.branches.persistence) as u8;
594
595                let pos = branch_order(&branch_name, &settings.branches.order);
596
597                let term_col = to_terminal_color(
598                    &branch_color(
599                        &branch_name,
600                        &settings.branches.terminal_colors[..],
601                        &settings.branches.terminal_colors_unknown,
602                        counter,
603                    )[..],
604                )?;
605                let svg_col = branch_color(
606                    &branch_name,
607                    &settings.branches.svg_colors,
608                    &settings.branches.svg_colors_unknown,
609                    counter,
610                );
611
612                let branch_info = BranchInfo::new(
613                    parent_oid,
614                    Some(info.oid),
615                    branch_name,
616                    persistence,
617                    false,
618                    true,
619                    false,
620                    BranchVis::new(pos, term_col, svg_col),
621                    Some(idx + 1),
622                );
623                valid_branches.push(branch_info);
624            }
625        }
626    }
627
628    valid_branches.sort_by_cached_key(|branch| (branch.persistence, !branch.is_merged));
629
630    let mut tags = Vec::new();
631
632    repository
633        .tag_foreach(|oid, name| {
634            tags.push((oid, name.to_vec()));
635            true
636        })
637        .map_err(|err| err.message().to_string())?;
638
639    for (oid, name) in tags {
640        let name = std::str::from_utf8(&name[5..]).map_err(|err| err.to_string())?;
641
642        let target = repository
643            .find_tag(oid)
644            .map(|tag| tag.target_id())
645            .or_else(|_| repository.find_commit(oid).map(|_| oid));
646
647        if let Ok(target_oid) = target {
648            if let Some(target_index) = indices.get(&target_oid) {
649                counter += 1;
650                let term_col = to_terminal_color(
651                    &branch_color(
652                        name,
653                        &settings.branches.terminal_colors[..],
654                        &settings.branches.terminal_colors_unknown,
655                        counter,
656                    )[..],
657                )?;
658                let pos = branch_order(name, &settings.branches.order);
659                let svg_col = branch_color(
660                    name,
661                    &settings.branches.svg_colors,
662                    &settings.branches.svg_colors_unknown,
663                    counter,
664                );
665                let tag_info = BranchInfo::new(
666                    target_oid,
667                    None,
668                    name.to_string(),
669                    settings.branches.persistence.len() as u8 + 1,
670                    false,
671                    false,
672                    true,
673                    BranchVis::new(pos, term_col, svg_col),
674                    Some(*target_index),
675                );
676                valid_branches.push(tag_info);
677            }
678        }
679    }
680
681    Ok(valid_branches)
682}
683
684/// Traces back branches by following 1st commit parent,
685/// until a commit is reached that already has a trace.
686fn trace_branch(
687    repository: &Repository,
688    commits: &mut [CommitInfo],
689    indices: &HashMap<Oid, usize>,
690    branches: &mut [BranchInfo],
691    oid: Oid,
692    branch_index: usize,
693) -> Result<bool, Error> {
694    let mut curr_oid = oid;
695    let mut prev_index: Option<usize> = None;
696    let mut start_index: Option<i32> = None;
697    let mut any_assigned = false;
698    while let Some(index) = indices.get(&curr_oid) {
699        let info = &mut commits[*index];
700        if let Some(old_trace) = info.branch_trace {
701            let (old_name, old_term, old_svg, old_range) = {
702                let old_branch = &branches[old_trace];
703                (
704                    &old_branch.name.clone(),
705                    old_branch.visual.term_color,
706                    old_branch.visual.svg_color.clone(),
707                    old_branch.range,
708                )
709            };
710            let new_name = &branches[branch_index].name;
711            let old_end = old_range.0.unwrap_or(0);
712            let new_end = branches[branch_index].range.0.unwrap_or(0);
713            if new_name == old_name && old_end >= new_end {
714                let old_branch = &mut branches[old_trace];
715                if let Some(old_end) = old_range.1 {
716                    if index > &old_end {
717                        old_branch.range = (None, None);
718                    } else {
719                        old_branch.range = (Some(*index), old_branch.range.1);
720                    }
721                } else {
722                    old_branch.range = (Some(*index), old_branch.range.1);
723                }
724            } else {
725                let branch = &mut branches[branch_index];
726                if branch.name.starts_with(ORIGIN) && branch.name[7..] == old_name[..] {
727                    branch.visual.term_color = old_term;
728                    branch.visual.svg_color = old_svg;
729                }
730                match prev_index {
731                    None => start_index = Some(*index as i32 - 1),
732                    Some(prev_index) => {
733                        // TODO: in cases where no crossings occur, the rule for merge commits can also be applied to normal commits
734                        // see also print::get_deviate_index()
735                        if commits[prev_index].is_merge {
736                            let mut temp_index = prev_index;
737                            for sibling_oid in &commits[*index].children {
738                                if sibling_oid != &curr_oid {
739                                    let sibling_index = indices[sibling_oid];
740                                    if sibling_index > temp_index {
741                                        temp_index = sibling_index;
742                                    }
743                                }
744                            }
745                            start_index = Some(temp_index as i32);
746                        } else {
747                            start_index = Some(*index as i32 - 1);
748                        }
749                    }
750                }
751                break;
752            }
753        }
754
755        info.branch_trace = Some(branch_index);
756        any_assigned = true;
757
758        let commit = repository.find_commit(curr_oid)?;
759        match commit.parent_count() {
760            0 => {
761                start_index = Some(*index as i32);
762                break;
763            }
764            _ => {
765                prev_index = Some(*index);
766                curr_oid = commit.parent_id(0)?;
767            }
768        }
769    }
770
771    let branch = &mut branches[branch_index];
772    if let Some(end) = branch.range.0 {
773        if let Some(start_index) = start_index {
774            if start_index < end as i32 {
775                // TODO: find a better solution (bool field?) to identify non-deleted branches that were not assigned to any commits, and thus should not occupy a column.
776                branch.range = (None, None);
777            } else {
778                branch.range = (branch.range.0, Some(start_index as usize));
779            }
780        } else {
781            branch.range = (branch.range.0, None);
782        }
783    } else {
784        branch.range = (branch.range.0, start_index.map(|si| si as usize));
785    }
786    Ok(any_assigned)
787}
788
789/// Sorts branches into columns for visualization, that all branches can be
790/// visualizes linearly and without overlaps. Uses Shortest-First scheduling.
791fn assign_branch_columns(
792    commits: &[CommitInfo],
793    indices: &HashMap<Oid, usize>,
794    branches: &mut [BranchInfo],
795    settings: &BranchSettings,
796    shortest_first: bool,
797    forward: bool,
798) {
799    let mut occupied: Vec<Vec<Vec<(usize, usize)>>> = vec![vec![]; settings.order.len() + 1];
800
801    let length_sort_factor = if shortest_first { 1 } else { -1 };
802    let start_sort_factor = if forward { 1 } else { -1 };
803
804    let mut branches_sort: Vec<_> = branches
805        .iter()
806        .enumerate()
807        .filter(|(_idx, br)| br.range.0.is_some() || br.range.1.is_some())
808        .map(|(idx, br)| {
809            (
810                idx,
811                br.range.0.unwrap_or(0),
812                br.range.1.unwrap_or(branches.len() - 1),
813                br.visual
814                    .source_order_group
815                    .unwrap_or(settings.order.len() + 1),
816                br.visual
817                    .target_order_group
818                    .unwrap_or(settings.order.len() + 1),
819            )
820        })
821        .collect();
822
823    branches_sort.sort_by_cached_key(|tup| {
824        (
825            std::cmp::max(tup.3, tup.4),
826            (tup.2 as i32 - tup.1 as i32) * length_sort_factor,
827            tup.1 as i32 * start_sort_factor,
828        )
829    });
830
831    for (branch_idx, start, end, _, _) in branches_sort {
832        let branch = &branches[branch_idx];
833        let group = branch.visual.order_group;
834        let group_occ = &mut occupied[group];
835
836        let align_right = branch
837            .source_branch
838            .map(|src| branches[src].visual.order_group > branch.visual.order_group)
839            .unwrap_or(false)
840            || branch
841                .target_branch
842                .map(|trg| branches[trg].visual.order_group > branch.visual.order_group)
843                .unwrap_or(false);
844
845        let len = group_occ.len();
846        let mut found = len;
847        for i in 0..len {
848            let index = if align_right { len - i - 1 } else { i };
849            let column_occ = &group_occ[index];
850            let mut occ = false;
851            for (s, e) in column_occ {
852                if start <= *e && end >= *s {
853                    occ = true;
854                    break;
855                }
856            }
857            if !occ {
858                if let Some(merge_trace) = branch
859                    .merge_target
860                    .and_then(|t| indices.get(&t))
861                    .and_then(|t_idx| commits[*t_idx].branch_trace)
862                {
863                    let merge_branch = &branches[merge_trace];
864                    if merge_branch.visual.order_group == branch.visual.order_group {
865                        if let Some(merge_column) = merge_branch.visual.column {
866                            if merge_column == index {
867                                occ = true;
868                            }
869                        }
870                    }
871                }
872            }
873            if !occ {
874                found = index;
875                break;
876            }
877        }
878
879        let branch = &mut branches[branch_idx];
880        branch.visual.column = Some(found);
881        if found == group_occ.len() {
882            group_occ.push(vec![]);
883        }
884        group_occ[found].push((start, end));
885    }
886
887    let group_offset: Vec<usize> = occupied
888        .iter()
889        .scan(0, |acc, group| {
890            *acc += group.len();
891            Some(*acc)
892        })
893        .collect();
894
895    for branch in branches {
896        if let Some(column) = branch.visual.column {
897            let offset = if branch.visual.order_group == 0 {
898                0
899            } else {
900                group_offset[branch.visual.order_group - 1]
901            };
902            branch.visual.column = Some(column + offset);
903        }
904    }
905}
906
907/// Finds the index for a branch name from a slice of prefixes
908fn branch_order(name: &str, order: &[Regex]) -> usize {
909    order
910        .iter()
911        .position(|b| (name.starts_with(ORIGIN) && b.is_match(&name[7..])) || b.is_match(name))
912        .unwrap_or(order.len())
913}
914
915/// Finds the svg color for a branch name.
916fn branch_color<T: Clone>(
917    name: &str,
918    order: &[(Regex, Vec<T>)],
919    unknown: &[T],
920    counter: usize,
921) -> T {
922    let color = order
923        .iter()
924        .find_position(|(b, _)| {
925            (name.starts_with(ORIGIN) && b.is_match(&name[7..])) || b.is_match(name)
926        })
927        .map(|(_pos, col)| &col.1[counter % col.1.len()])
928        .unwrap_or_else(|| &unknown[counter % unknown.len()]);
929    color.clone()
930}
931
932/// Tries to extract the name of a merged-in branch from the merge commit summary.
933pub fn parse_merge_summary(summary: &str, patterns: &MergePatterns) -> Option<String> {
934    for regex in &patterns.patterns {
935        if let Some(captures) = regex.captures(summary) {
936            if captures.len() == 2 && captures.get(1).is_some() {
937                return captures.get(1).map(|m| m.as_str().to_string());
938            }
939        }
940    }
941    None
942}
943
944#[cfg(test)]
945mod tests {
946    use crate::settings::MergePatterns;
947
948    #[test]
949    fn parse_merge_summary() {
950        let patterns = MergePatterns::default();
951
952        let gitlab_pull = "Merge branch 'feature/my-feature' into 'master'";
953        let git_default = "Merge branch 'feature/my-feature' into dev";
954        let git_master = "Merge branch 'feature/my-feature'";
955        let github_pull = "Merge pull request #1 from user-x/feature/my-feature";
956        let github_pull_2 = "Merge branch 'feature/my-feature' of github.com:user-x/repo";
957        let bitbucket_pull = "Merged in feature/my-feature (pull request #1)";
958
959        assert_eq!(
960            super::parse_merge_summary(gitlab_pull, &patterns),
961            Some("feature/my-feature".to_string()),
962        );
963        assert_eq!(
964            super::parse_merge_summary(git_default, &patterns),
965            Some("feature/my-feature".to_string()),
966        );
967        assert_eq!(
968            super::parse_merge_summary(git_master, &patterns),
969            Some("feature/my-feature".to_string()),
970        );
971        assert_eq!(
972            super::parse_merge_summary(github_pull, &patterns),
973            Some("feature/my-feature".to_string()),
974        );
975        assert_eq!(
976            super::parse_merge_summary(github_pull_2, &patterns),
977            Some("feature/my-feature".to_string()),
978        );
979        assert_eq!(
980            super::parse_merge_summary(bitbucket_pull, &patterns),
981            Some("feature/my-feature".to_string()),
982        );
983    }
984}