git_graph/
graph.rs

1//! A graph structure representing the history of a Git repository.
2//!
3//! To generate a graph, call [GitGraph::new()].
4//!
5//! ### Visualization of branches
6//! git-graph uses the term *branch* a little different from how git uses it.
7//! In git-lingo this means "a label on some commit", whereas in git-graph
8//! it means "a path in the ancestor graph of a repository". Nodes are
9//! commits, edges are directed from a child to its parents.
10//!
11//! In the text below, the term
12//! - *git-branch* is a label on a commit.
13//! - *branch* is the visualization of an ancestor path.
14//!
15//! git-graph visualizes branches as a vertical line. Only
16//! the primary parent of a commit can be on the same branch as the
17//! commit. Horizontal lines represent forks (multiple children) or
18//! merges (multiple parents), and show the remaining parent relations.
19
20use crate::print::colors::to_terminal_color;
21use crate::settings::{BranchOrder, BranchSettings, MergePatterns, Settings};
22use git2::{BranchType, Commit, Error, Oid, Reference, Repository};
23use regex::Regex;
24use std::collections::{HashMap, HashSet};
25
26const ORIGIN: &str = "origin/";
27const FORK: &str = "fork/";
28
29/// Represents a git history graph.
30pub struct GitGraph {
31    pub repository: Repository,
32    pub commits: Vec<CommitInfo>,
33    /// Mapping from commit id to index in `commits`
34    pub indices: HashMap<Oid, usize>,
35    /// All detected branches and tags, including merged and deleted
36    pub all_branches: Vec<BranchInfo>,
37    /// The current HEAD
38    pub head: HeadInfo,
39}
40
41impl GitGraph {
42    /// Generate a branch graph for a repository
43    pub fn new(
44        mut repository: Repository,
45        settings: &Settings,
46        start_point: Option<String>,
47        max_count: Option<usize>,
48    ) -> Result<Self, String> {
49        #![doc = include_str!("../docs/branch_assignment.md")]
50        let mut stashes = HashSet::new();
51        repository
52            .stash_foreach(|_, _, oid| {
53                stashes.insert(*oid);
54                true
55            })
56            .map_err(|err| err.message().to_string())?;
57
58        let mut walk = repository
59            .revwalk()
60            .map_err(|err| err.message().to_string())?;
61
62        walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
63            .map_err(|err| err.message().to_string())?;
64
65        // Use starting point if specified
66        if let Some(start) = start_point {
67            let object = repository
68                .revparse_single(&start)
69                .map_err(|err| format!("Failed to resolve start point '{}': {}", start, err))?;
70            walk.push(object.id())
71                .map_err(|err| err.message().to_string())?;
72        } else {
73            walk.push_glob("*")
74                .map_err(|err| err.message().to_string())?;
75        }
76
77        if repository.is_shallow() {
78            return Err("ERROR: git-graph does not support shallow clones due to a missing feature in the underlying libgit2 library.".to_string());
79        }
80
81        let head = HeadInfo::new(&repository.head().map_err(|err| err.message().to_string())?)?;
82
83        // commits will hold the CommitInfo for all commits covered
84        // indices maps git object id to an index into commits.
85        let mut commits = Vec::new();
86        let mut indices = HashMap::new();
87        let mut idx = 0;
88        for oid in walk {
89            if let Some(max) = max_count {
90                if idx >= max {
91                    break;
92                }
93            }
94            if let Ok(oid) = oid {
95                if !stashes.contains(&oid) {
96                    let commit = repository.find_commit(oid).unwrap();
97
98                    commits.push(CommitInfo::new(&commit));
99                    indices.insert(oid, idx);
100                    idx += 1;
101                }
102            }
103        }
104
105        assign_children(&mut commits, &indices);
106
107        let mut all_branches = assign_branches(&repository, &mut commits, &indices, settings)?;
108        correct_fork_merges(&commits, &indices, &mut all_branches, settings)?;
109        assign_sources_targets(&commits, &indices, &mut all_branches);
110
111        let (shortest_first, forward) = match settings.branch_order {
112            BranchOrder::ShortestFirst(fwd) => (true, fwd),
113            BranchOrder::LongestFirst(fwd) => (false, fwd),
114        };
115
116        assign_branch_columns(
117            &commits,
118            &indices,
119            &mut all_branches,
120            &settings.branches,
121            shortest_first,
122            forward,
123        );
124
125        // Remove commits not on a branch. This will give all commits a new index.
126        let filtered_commits: Vec<CommitInfo> = commits
127            .into_iter()
128            .filter(|info| info.branch_trace.is_some())
129            .collect();
130
131        // Create indices from git object id into the filtered commits
132        let filtered_indices: HashMap<Oid, usize> = filtered_commits
133            .iter()
134            .enumerate()
135            .map(|(idx, info)| (info.oid, idx))
136            .collect();
137
138        // Map from old index to new index. None, if old index was removed
139        let index_map: HashMap<usize, Option<&usize>> = indices
140            .iter()
141            .map(|(oid, index)| (*index, filtered_indices.get(oid)))
142            .collect();
143
144        // Update branch.range from old to new index. Shrink if endpoints were removed.
145        for branch in all_branches.iter_mut() {
146            if let Some(mut start_idx) = branch.range.0 {
147                let mut idx0 = index_map[&start_idx];
148                while idx0.is_none() {
149                    start_idx += 1;
150                    idx0 = index_map[&start_idx];
151                }
152                branch.range.0 = Some(*idx0.unwrap());
153            }
154            if let Some(mut end_idx) = branch.range.1 {
155                let mut idx0 = index_map[&end_idx];
156                while idx0.is_none() {
157                    end_idx -= 1;
158                    idx0 = index_map[&end_idx];
159                }
160                branch.range.1 = Some(*idx0.unwrap());
161            }
162        }
163
164        Ok(GitGraph {
165            repository,
166            commits: filtered_commits,
167            indices: filtered_indices,
168            all_branches,
169            head,
170        })
171    }
172
173    pub fn take_repository(self) -> Repository {
174        self.repository
175    }
176
177    pub fn commit(&self, id: Oid) -> Result<Commit<'_>, Error> {
178        self.repository.find_commit(id)
179    }
180}
181
182/// Information about the current HEAD
183pub struct HeadInfo {
184    pub oid: Oid,
185    pub name: String,
186    pub is_branch: bool,
187}
188impl HeadInfo {
189    fn new(head: &Reference) -> Result<Self, String> {
190        let name = head.name().ok_or_else(|| "No name for HEAD".to_string())?;
191        let name = if name == "HEAD" {
192            name.to_string()
193        } else {
194            name[11..].to_string()
195        };
196
197        let h = HeadInfo {
198            oid: head.target().ok_or_else(|| "No id for HEAD".to_string())?,
199            name,
200            is_branch: head.is_branch(),
201        };
202        Ok(h)
203    }
204}
205
206/// Represents a commit.
207pub struct CommitInfo {
208    pub oid: Oid,
209    pub is_merge: bool,
210    pub parents: [Option<Oid>; 2],
211    pub children: Vec<Oid>,
212    pub branches: Vec<usize>,
213    pub tags: Vec<usize>,
214    pub branch_trace: Option<usize>,
215}
216
217impl CommitInfo {
218    fn new(commit: &Commit) -> Self {
219        CommitInfo {
220            oid: commit.id(),
221            is_merge: commit.parent_count() > 1,
222            parents: [commit.parent_id(0).ok(), commit.parent_id(1).ok()],
223            children: Vec::new(),
224            branches: Vec::new(),
225            tags: Vec::new(),
226            branch_trace: None,
227        }
228    }
229}
230
231/// Represents a branch (real or derived from merge summary).
232pub struct BranchInfo {
233    pub target: Oid,
234    pub merge_target: Option<Oid>,
235    pub source_branch: Option<usize>,
236    pub target_branch: Option<usize>,
237    pub name: String,
238    pub persistence: u8,
239    pub is_remote: bool,
240    pub is_merged: bool,
241    pub is_tag: bool,
242    pub visual: BranchVis,
243    pub range: (Option<usize>, Option<usize>),
244}
245impl BranchInfo {
246    #[allow(clippy::too_many_arguments)]
247    fn new(
248        target: Oid,
249        merge_target: Option<Oid>,
250        name: String,
251        persistence: u8,
252        is_remote: bool,
253        is_merged: bool,
254        is_tag: bool,
255        visual: BranchVis,
256        end_index: Option<usize>,
257    ) -> Self {
258        BranchInfo {
259            target,
260            merge_target,
261            target_branch: None,
262            source_branch: None,
263            name,
264            persistence,
265            is_remote,
266            is_merged,
267            is_tag,
268            visual,
269            range: (end_index, None),
270        }
271    }
272}
273
274/// Branch properties for visualization.
275pub struct BranchVis {
276    /// The branch's column group (left to right)
277    pub order_group: usize,
278    /// The branch's merge target column group (left to right)
279    pub target_order_group: Option<usize>,
280    /// The branch's source branch column group (left to right)
281    pub source_order_group: Option<usize>,
282    /// The branch's terminal color (index in 256-color palette)
283    pub term_color: u8,
284    /// SVG color (name or RGB in hex annotation)
285    pub svg_color: String,
286    /// The column the branch is located in
287    pub column: Option<usize>,
288}
289
290impl BranchVis {
291    fn new(order_group: usize, term_color: u8, svg_color: String) -> Self {
292        BranchVis {
293            order_group,
294            target_order_group: None,
295            source_order_group: None,
296            term_color,
297            svg_color,
298            column: None,
299        }
300    }
301}
302
303/// Walks through the commits and adds each commit's Oid to the children of its parents.
304fn assign_children(commits: &mut [CommitInfo], indices: &HashMap<Oid, usize>) {
305    for idx in 0..commits.len() {
306        let (oid, parents) = {
307            let info = &commits[idx];
308            (info.oid, info.parents)
309        };
310        for par_oid in &parents {
311            if let Some(par_idx) = par_oid.and_then(|oid| indices.get(&oid)) {
312                commits[*par_idx].children.push(oid);
313            }
314        }
315    }
316}
317
318/// Extracts branches from repository and merge summaries, assigns branches and branch traces to commits.
319///
320/// Algorithm:
321/// * Find all actual branches (incl. target oid) and all extract branches from merge summaries (incl. parent oid)
322/// * Sort all branches by persistence
323/// * Iterating over all branches in persistence order, trace back over commit parents until a trace is already assigned
324fn assign_branches(
325    repository: &Repository,
326    commits: &mut [CommitInfo],
327    indices: &HashMap<Oid, usize>,
328    settings: &Settings,
329) -> Result<Vec<BranchInfo>, String> {
330    let mut branch_idx = 0;
331
332    let mut branches = extract_branches(repository, commits, indices, settings)?;
333
334    let mut index_map: Vec<_> = (0..branches.len())
335        .map(|old_idx| {
336            let (target, is_tag, is_merged) = {
337                let branch = &branches[old_idx];
338                (branch.target, branch.is_tag, branch.is_merged)
339            };
340            if let Some(&idx) = &indices.get(&target) {
341                let info = &mut commits[idx];
342                if is_tag {
343                    info.tags.push(old_idx);
344                } else if !is_merged {
345                    info.branches.push(old_idx);
346                }
347                let oid = info.oid;
348                let any_assigned =
349                    trace_branch(repository, commits, indices, &mut branches, oid, old_idx)
350                        .unwrap_or(false);
351
352                if any_assigned || !is_merged {
353                    branch_idx += 1;
354                    Some(branch_idx - 1)
355                } else {
356                    None
357                }
358            } else {
359                None
360            }
361        })
362        .collect();
363
364    let mut commit_count = vec![0; branches.len()];
365    for info in commits.iter_mut() {
366        if let Some(trace) = info.branch_trace {
367            commit_count[trace] += 1;
368        }
369    }
370
371    let mut count_skipped = 0;
372    for (idx, branch) in branches.iter().enumerate() {
373        if let Some(mapped) = index_map[idx] {
374            if commit_count[idx] == 0 && branch.is_merged && !branch.is_tag {
375                index_map[idx] = None;
376                count_skipped += 1;
377            } else {
378                index_map[idx] = Some(mapped - count_skipped);
379            }
380        }
381    }
382
383    for info in commits.iter_mut() {
384        if let Some(trace) = info.branch_trace {
385            info.branch_trace = index_map[trace];
386            for br in info.branches.iter_mut() {
387                *br = index_map[*br].unwrap();
388            }
389            for tag in info.tags.iter_mut() {
390                *tag = index_map[*tag].unwrap();
391            }
392        }
393    }
394
395    let branches: Vec<_> = branches
396        .into_iter()
397        .enumerate()
398        .filter_map(|(arr_index, branch)| {
399            if index_map[arr_index].is_some() {
400                Some(branch)
401            } else {
402                None
403            }
404        })
405        .collect();
406
407    Ok(branches)
408}
409
410fn correct_fork_merges(
411    commits: &[CommitInfo],
412    indices: &HashMap<Oid, usize>,
413    branches: &mut [BranchInfo],
414    settings: &Settings,
415) -> Result<(), String> {
416    for idx in 0..branches.len() {
417        if let Some(merge_target) = branches[idx]
418            .merge_target
419            .and_then(|oid| indices.get(&oid))
420            .and_then(|idx| commits.get(*idx))
421            .and_then(|info| info.branch_trace)
422            .and_then(|trace| branches.get(trace))
423        {
424            if branches[idx].name == merge_target.name {
425                let name = format!("{}{}", FORK, branches[idx].name);
426                let term_col = to_terminal_color(
427                    &branch_color(
428                        &name,
429                        &settings.branches.terminal_colors[..],
430                        &settings.branches.terminal_colors_unknown,
431                        idx,
432                    )[..],
433                )?;
434                let pos = branch_order(&name, &settings.branches.order);
435                let svg_col = branch_color(
436                    &name,
437                    &settings.branches.svg_colors,
438                    &settings.branches.svg_colors_unknown,
439                    idx,
440                );
441
442                branches[idx].name = format!("{}{}", FORK, branches[idx].name);
443                branches[idx].visual.order_group = pos;
444                branches[idx].visual.term_color = term_col;
445                branches[idx].visual.svg_color = svg_col;
446            }
447        }
448    }
449    Ok(())
450}
451fn assign_sources_targets(
452    commits: &[CommitInfo],
453    indices: &HashMap<Oid, usize>,
454    branches: &mut [BranchInfo],
455) {
456    for idx in 0..branches.len() {
457        let target_branch_idx = branches[idx]
458            .merge_target
459            .and_then(|oid| indices.get(&oid))
460            .and_then(|idx| commits.get(*idx))
461            .and_then(|info| info.branch_trace);
462
463        branches[idx].target_branch = target_branch_idx;
464
465        let group = target_branch_idx
466            .and_then(|trace| branches.get(trace))
467            .map(|br| br.visual.order_group);
468
469        branches[idx].visual.target_order_group = group;
470    }
471    for info in commits {
472        let mut max_par_order = None;
473        let mut source_branch_id = None;
474        for par_oid in info.parents.iter() {
475            let par_info = par_oid
476                .and_then(|oid| indices.get(&oid))
477                .and_then(|idx| commits.get(*idx));
478            if let Some(par_info) = par_info {
479                if par_info.branch_trace != info.branch_trace {
480                    if let Some(trace) = par_info.branch_trace {
481                        source_branch_id = Some(trace);
482                    }
483
484                    let group = par_info
485                        .branch_trace
486                        .and_then(|trace| branches.get(trace))
487                        .map(|br| br.visual.order_group);
488                    if let Some(gr) = max_par_order {
489                        if let Some(p_group) = group {
490                            if p_group > gr {
491                                max_par_order = group;
492                            }
493                        }
494                    } else {
495                        max_par_order = group;
496                    }
497                }
498            }
499        }
500        let branch = info.branch_trace.and_then(|trace| branches.get_mut(trace));
501        if let Some(branch) = branch {
502            if let Some(order) = max_par_order {
503                branch.visual.source_order_group = Some(order);
504            }
505            if let Some(source_id) = source_branch_id {
506                branch.source_branch = Some(source_id);
507            }
508        }
509    }
510}
511
512/// Extracts and processes actual Git branches (local and remote) from the repository.
513///
514/// This function iterates through the branches found in the Git repository,
515/// filters them based on the `include_remote` setting, and constructs `BranchInfo`
516/// objects for each valid branch. It assigns properties like name, type (local/remote),
517/// visual order, and colors based on the provided settings.
518///
519/// Arguments:
520/// - `repository`: A reference to the Git `Repository` object.
521/// - `indices`: A HashMap mapping commit OIDs to their corresponding indices in the `commits` list.
522/// - `settings`: A reference to the application `Settings` containing branch configuration.
523/// - `counter`: A mutable reference to a counter, incremented for each processed branch to aid in color assignment.
524///
525/// Returns:
526/// A `Result` containing a `Vec<BranchInfo>` on success, or a `String` error message on failure.
527fn extract_actual_branches(
528    repository: &Repository,
529    indices: &HashMap<Oid, usize>,
530    settings: &Settings,
531    counter: &mut usize,
532) -> Result<Vec<BranchInfo>, String> {
533    // Determine if remote branches should be included based on settings.
534    let filter = if settings.include_remote {
535        None
536    } else {
537        Some(BranchType::Local)
538    };
539
540    // Retrieve branches from the repository, handling potential errors.
541    let actual_branches = repository
542        .branches(filter)
543        .map_err(|err| err.message().to_string())?
544        .collect::<Result<Vec<_>, Error>>()
545        .map_err(|err| err.message().to_string())?;
546
547    // Process each actual branch to create `BranchInfo` objects.
548    let valid_branches = actual_branches
549        .iter()
550        .filter_map(|(br, tp)| {
551            br.get().name().and_then(|n| {
552                br.get().target().map(|t| {
553                    *counter += 1; // Increment counter for unique branch identification/coloring.
554
555                    // Determine the starting index for slicing the branch name string.
556                    let start_index = match tp {
557                        BranchType::Local => 11,  // "refs/heads/"
558                        BranchType::Remote => 13, // "refs/remotes/"
559                    };
560                    let name = &n[start_index..];
561                    let end_index = indices.get(&t).cloned();
562
563                    // Convert branch color to a terminal-compatible format.
564                    let term_color = match to_terminal_color(
565                        &branch_color(
566                            name,
567                            &settings.branches.terminal_colors[..],
568                            &settings.branches.terminal_colors_unknown,
569                            *counter,
570                        )[..],
571                    ) {
572                        Ok(col) => col,
573                        Err(err) => return Err(err), // Propagate color conversion errors.
574                    };
575
576                    // Create and return the BranchInfo object.
577                    Ok(BranchInfo::new(
578                        t,
579                        None, // No merge OID for actual branches.
580                        name.to_string(),
581                        branch_order(name, &settings.branches.persistence) as u8,
582                        &BranchType::Remote == tp, // Check if it's a remote branch.
583                        false,                     // Not a derived merge branch.
584                        false,                     // Not a tag.
585                        BranchVis::new(
586                            branch_order(name, &settings.branches.order),
587                            term_color,
588                            branch_color(
589                                name,
590                                &settings.branches.svg_colors,
591                                &settings.branches.svg_colors_unknown,
592                                *counter,
593                            ),
594                        ),
595                        end_index,
596                    ))
597                })
598            })
599        })
600        .collect::<Result<Vec<_>, String>>()?; // Collect results, propagating any errors.
601
602    Ok(valid_branches)
603}
604
605/// Iterates through commits, identifies merge commits, and derives branch information
606/// from their summaries.
607///
608/// This function processes each commit in the provided list. If a commit is identified
609/// as a merge commit and has a summary, it attempts to parse a branch name from the summary.
610/// A `BranchInfo` object is then created for this derived branch, representing the merge
611/// point and its properties.
612///
613/// Arguments:
614/// - `repository`: A reference to the Git `Repository` object.
615/// - `commits`: A slice of `CommitInfo` objects, representing the commits to process.
616/// - `settings`: A reference to the application `Settings` containing branch and merge pattern configuration.
617/// - `counter`: A mutable reference to a counter, incremented for each processed merge branch.
618///
619/// Returns:
620/// A `Result` containing a `Vec<BranchInfo>` on success, or a `String` error message on failure.
621fn extract_merge_branches(
622    repository: &Repository,
623    commits: &[CommitInfo],
624    settings: &Settings,
625    counter: &mut usize,
626) -> Result<Vec<BranchInfo>, String> {
627    let mut merge_branches = Vec::new();
628
629    for (idx, info) in commits.iter().enumerate() {
630        // Only process if the commit is a merge.
631        if info.is_merge {
632            let commit = repository
633                .find_commit(info.oid)
634                .map_err(|err| err.message().to_string())?;
635
636            // Attempt to get the commit summary.
637            if let Some(summary) = commit.summary() {
638                *counter += 1; // Increment counter for unique branch identification/coloring.
639
640                let parent_oid = commit
641                    .parent_id(1)
642                    .map_err(|err| err.message().to_string())?;
643
644                // Parse the branch name from the merge summary using configured patterns.
645                let branch_name = parse_merge_summary(summary, &settings.merge_patterns)
646                    .unwrap_or_else(|| "unknown".to_string());
647
648                // Determine persistence and order for the derived branch.
649                let persistence = branch_order(&branch_name, &settings.branches.persistence) as u8;
650                let pos = branch_order(&branch_name, &settings.branches.order);
651
652                // Get terminal and SVG colors for the branch.
653                let term_col = to_terminal_color(
654                    &branch_color(
655                        &branch_name,
656                        &settings.branches.terminal_colors[..],
657                        &settings.branches.terminal_colors_unknown,
658                        *counter,
659                    )[..],
660                )?;
661                let svg_col = branch_color(
662                    &branch_name,
663                    &settings.branches.svg_colors,
664                    &settings.branches.svg_colors_unknown,
665                    *counter,
666                );
667
668                // Create and add the BranchInfo for the derived merge branch.
669                let branch_info = BranchInfo::new(
670                    parent_oid,     // Target is the parent of the merge.
671                    Some(info.oid), // The merge commit itself.
672                    branch_name,
673                    persistence,
674                    false, // Not a remote branch.
675                    true,  // This is a derived merge branch.
676                    false, // Not a tag.
677                    BranchVis::new(pos, term_col, svg_col),
678                    Some(idx + 1), // End index typically points to the commit after the merge.
679                );
680                merge_branches.push(branch_info);
681            }
682        }
683    }
684    Ok(merge_branches)
685}
686
687/// Extracts Git tags and treats them as branches, assigning appropriate properties.
688///
689/// This function iterates through all tags in the repository, resolves their target
690/// commit OID, and if the target commit is found within the `commits` list,
691/// a `BranchInfo` object is created for the tag. Tags are assigned a higher
692/// persistence value to ensure they are displayed prominently.
693///
694/// Arguments:
695/// - `repository`: A reference to the Git `Repository` object.
696/// - `indices`: A HashMap mapping commit OIDs to their corresponding indices in the `commits` list.
697/// - `settings`: A reference to the application `Settings` containing branch configuration.
698/// - `counter`: A mutable reference to a counter, incremented for each processed tag.
699///
700/// Returns:
701/// A `Result` containing a `Vec<BranchInfo>` on success, or a `String` error message on failure.
702fn extract_tags_as_branches(
703    repository: &Repository,
704    indices: &HashMap<Oid, usize>,
705    settings: &Settings,
706    counter: &mut usize,
707) -> Result<Vec<BranchInfo>, String> {
708    let mut tags_info = Vec::new();
709    let mut tags_raw = Vec::new();
710
711    // Iterate over all tags in the repository.
712    repository
713        .tag_foreach(|oid, name| {
714            tags_raw.push((oid, name.to_vec()));
715            true // Continue iteration.
716        })
717        .map_err(|err| err.message().to_string())?;
718
719    for (oid, name_bytes) in tags_raw {
720        // Convert tag name bytes to a UTF-8 string. Tags typically start with "refs/tags/".
721        let name = std::str::from_utf8(&name_bytes[5..]).map_err(|err| err.to_string())?;
722
723        // Resolve the target OID of the tag. It could be a tag object or directly a commit.
724        let target = repository
725            .find_tag(oid)
726            .map(|tag| tag.target_id())
727            .or_else(|_| repository.find_commit(oid).map(|_| oid)); // If not a tag object, try as a direct commit.
728
729        if let Ok(target_oid) = target {
730            // If the target commit is within our processed commits, create a BranchInfo.
731            if let Some(target_index) = indices.get(&target_oid) {
732                *counter += 1; // Increment counter for unique tag identification/coloring.
733
734                // Get terminal and SVG colors for the tag.
735                let term_col = to_terminal_color(
736                    &branch_color(
737                        name,
738                        &settings.branches.terminal_colors[..],
739                        &settings.branches.terminal_colors_unknown,
740                        *counter,
741                    )[..],
742                )?;
743                let pos = branch_order(name, &settings.branches.order);
744                let svg_col = branch_color(
745                    name,
746                    &settings.branches.svg_colors,
747                    &settings.branches.svg_colors_unknown,
748                    *counter,
749                );
750
751                // Create the BranchInfo object for the tag.
752                let tag_info = BranchInfo::new(
753                    target_oid,
754                    None, // No merge OID for tags.
755                    name.to_string(),
756                    settings.branches.persistence.len() as u8 + 1, // Tags usually have highest persistence.
757                    false,                                         // Not a remote branch.
758                    false,                                         // Not a derived merge branch.
759                    true,                                          // This is a tag.
760                    BranchVis::new(pos, term_col, svg_col),
761                    Some(*target_index),
762                );
763                tags_info.push(tag_info);
764            }
765        }
766    }
767    Ok(tags_info)
768}
769
770/// Extracts (real or derived from merge summary) and assigns basic properties to branches and tags.
771///
772/// This function orchestrates the extraction of branch information from various sources:
773/// 1. Actual Git branches (local and remote).
774/// 2. Branches derived from merge commit summaries.
775/// 3. Git tags, treated as branches for visualization purposes.
776///
777/// It combines the results from these extraction steps, sorts them based on
778/// persistence and merge status, and returns a comprehensive list of `BranchInfo` objects.
779///
780/// Arguments:
781/// - `repository`: A reference to the Git `Repository` object.
782/// - `commits`: A slice of `CommitInfo` objects, representing all relevant commits.
783/// - `indices`: A HashMap mapping commit OIDs to their corresponding indices in the `commits` list.
784/// - `settings`: A reference to the application `Settings` containing all necessary configuration.
785///
786/// Returns:
787/// A `Result` containing a `Vec<BranchInfo>` on success, or a `String` error message on failure.
788fn extract_branches(
789    repository: &Repository,
790    commits: &[CommitInfo],
791    indices: &HashMap<Oid, usize>,
792    settings: &Settings,
793) -> Result<Vec<BranchInfo>, String> {
794    let mut counter = 0; // Counter for unique branch/tag identification, especially for coloring.
795    let mut all_branches: Vec<BranchInfo> = Vec::new();
796
797    // 1. Extract actual local and remote branches.
798    let actual_branches = extract_actual_branches(repository, indices, settings, &mut counter)?;
799    all_branches.extend(actual_branches);
800
801    // 2. Extract branches derived from merge commit summaries.
802    let merge_branches = extract_merge_branches(repository, commits, settings, &mut counter)?;
803    all_branches.extend(merge_branches);
804
805    // 3. Extract tags and treat them as branches for visualization.
806    let tags_as_branches = extract_tags_as_branches(repository, indices, settings, &mut counter)?;
807    all_branches.extend(tags_as_branches);
808
809    // Sort all collected branches and tags.
810    // Sorting criteria: first by persistence, then by whether they are merged (unmerged first).
811    all_branches.sort_by_cached_key(|branch| (branch.persistence, !branch.is_merged));
812
813    Ok(all_branches)
814}
815
816/// Traces back branches by following 1st commit parent,
817/// until a commit is reached that already has a trace.
818fn trace_branch(
819    repository: &Repository,
820    commits: &mut [CommitInfo],
821    indices: &HashMap<Oid, usize>,
822    branches: &mut [BranchInfo],
823    oid: Oid,
824    branch_index: usize,
825) -> Result<bool, Error> {
826    let mut curr_oid = oid;
827    let mut prev_index: Option<usize> = None;
828    let mut start_index: Option<i32> = None;
829    let mut any_assigned = false;
830    while let Some(index) = indices.get(&curr_oid) {
831        let info = &mut commits[*index];
832        if let Some(old_trace) = info.branch_trace {
833            let (old_name, old_term, old_svg, old_range) = {
834                let old_branch = &branches[old_trace];
835                (
836                    &old_branch.name.clone(),
837                    old_branch.visual.term_color,
838                    old_branch.visual.svg_color.clone(),
839                    old_branch.range,
840                )
841            };
842            let new_name = &branches[branch_index].name;
843            let old_end = old_range.0.unwrap_or(0);
844            let new_end = branches[branch_index].range.0.unwrap_or(0);
845            if new_name == old_name && old_end >= new_end {
846                let old_branch = &mut branches[old_trace];
847                if let Some(old_end) = old_range.1 {
848                    if index > &old_end {
849                        old_branch.range = (None, None);
850                    } else {
851                        old_branch.range = (Some(*index), old_branch.range.1);
852                    }
853                } else {
854                    old_branch.range = (Some(*index), old_branch.range.1);
855                }
856            } else {
857                let branch = &mut branches[branch_index];
858                if branch.name.starts_with(ORIGIN) && branch.name[7..] == old_name[..] {
859                    branch.visual.term_color = old_term;
860                    branch.visual.svg_color = old_svg;
861                }
862                match prev_index {
863                    None => start_index = Some(*index as i32 - 1),
864                    Some(prev_index) => {
865                        // TODO: in cases where no crossings occur, the rule for merge commits can also be applied to normal commits
866                        // see also print::get_deviate_index()
867                        if commits[prev_index].is_merge {
868                            let mut temp_index = prev_index;
869                            for sibling_oid in &commits[*index].children {
870                                if sibling_oid != &curr_oid {
871                                    let sibling_index = indices[sibling_oid];
872                                    if sibling_index > temp_index {
873                                        temp_index = sibling_index;
874                                    }
875                                }
876                            }
877                            start_index = Some(temp_index as i32);
878                        } else {
879                            start_index = Some(*index as i32 - 1);
880                        }
881                    }
882                }
883                break;
884            }
885        }
886
887        info.branch_trace = Some(branch_index);
888        any_assigned = true;
889
890        let commit = repository.find_commit(curr_oid)?;
891        if commit.parent_count() == 0 {
892            // If no parents, this is the root commit, set `start_index` and break.
893            start_index = Some(*index as i32);
894            break;
895        }
896        // Set `prev_index` to the current commit's index and move to the first parent.
897        prev_index = Some(*index);
898        curr_oid = commit.parent_id(0)?;
899    }
900
901    let branch = &mut branches[branch_index];
902    if let Some(end) = branch.range.0 {
903        if let Some(start_index) = start_index {
904            if start_index < end as i32 {
905                // 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.
906                branch.range = (None, None);
907            } else {
908                branch.range = (branch.range.0, Some(start_index as usize));
909            }
910        } else {
911            branch.range = (branch.range.0, None);
912        }
913    } else {
914        branch.range = (branch.range.0, start_index.map(|si| si as usize));
915    }
916    Ok(any_assigned)
917}
918
919/// Sorts branches into columns for visualization, that all branches can be
920/// visualizes linearly and without overlaps. Uses Shortest-First scheduling.
921fn assign_branch_columns(
922    commits: &[CommitInfo],
923    indices: &HashMap<Oid, usize>,
924    branches: &mut [BranchInfo],
925    settings: &BranchSettings,
926    shortest_first: bool,
927    forward: bool,
928) {
929    let mut occupied: Vec<Vec<Vec<(usize, usize)>>> = vec![vec![]; settings.order.len() + 1];
930
931    let length_sort_factor = if shortest_first { 1 } else { -1 };
932    let start_sort_factor = if forward { 1 } else { -1 };
933
934    let mut branches_sort: Vec<_> = branches
935        .iter()
936        .enumerate()
937        .filter(|(_idx, br)| br.range.0.is_some() || br.range.1.is_some())
938        .map(|(idx, br)| {
939            (
940                idx,
941                br.range.0.unwrap_or(0),
942                br.range.1.unwrap_or(branches.len() - 1),
943                br.visual
944                    .source_order_group
945                    .unwrap_or(settings.order.len() + 1),
946                br.visual
947                    .target_order_group
948                    .unwrap_or(settings.order.len() + 1),
949            )
950        })
951        .collect();
952
953    branches_sort.sort_by_cached_key(|tup| {
954        (
955            std::cmp::max(tup.3, tup.4),
956            (tup.2 as i32 - tup.1 as i32) * length_sort_factor,
957            tup.1 as i32 * start_sort_factor,
958        )
959    });
960
961    for (branch_idx, start, end, _, _) in branches_sort {
962        let branch = &branches[branch_idx];
963        let group = branch.visual.order_group;
964        let group_occ = &mut occupied[group];
965
966        let align_right = branch
967            .source_branch
968            .map(|src| branches[src].visual.order_group > branch.visual.order_group)
969            .unwrap_or(false)
970            || branch
971                .target_branch
972                .map(|trg| branches[trg].visual.order_group > branch.visual.order_group)
973                .unwrap_or(false);
974
975        let len = group_occ.len();
976        let mut found = len;
977        for i in 0..len {
978            let index = if align_right { len - i - 1 } else { i };
979            let column_occ = &group_occ[index];
980            let mut occ = false;
981            for (s, e) in column_occ {
982                if start <= *e && end >= *s {
983                    occ = true;
984                    break;
985                }
986            }
987            if !occ {
988                if let Some(merge_trace) = branch
989                    .merge_target
990                    .and_then(|t| indices.get(&t))
991                    .and_then(|t_idx| commits[*t_idx].branch_trace)
992                {
993                    let merge_branch = &branches[merge_trace];
994                    if merge_branch.visual.order_group == branch.visual.order_group {
995                        if let Some(merge_column) = merge_branch.visual.column {
996                            if merge_column == index {
997                                occ = true;
998                            }
999                        }
1000                    }
1001                }
1002            }
1003            if !occ {
1004                found = index;
1005                break;
1006            }
1007        }
1008
1009        let branch = &mut branches[branch_idx];
1010        branch.visual.column = Some(found);
1011        if found == group_occ.len() {
1012            group_occ.push(vec![]);
1013        }
1014        group_occ[found].push((start, end));
1015    }
1016
1017    // Compute start column of each group
1018    let mut group_offset: Vec<usize> = vec![];
1019    let mut acc = 0;
1020    for group in occupied {
1021        group_offset.push(acc);
1022        acc += group.len();
1023    }
1024
1025    // Compute branch column. Up till now we have computed the branch group
1026    // and the column offset within that group. This was to make it easy to
1027    // insert columns between groups. Now it is time to convert offset relative
1028    // to the group the final column.
1029    for branch in branches {
1030        if let Some(column) = branch.visual.column {
1031            let offset = group_offset[branch.visual.order_group];
1032            branch.visual.column = Some(column + offset);
1033        }
1034    }
1035}
1036
1037/// Finds the index for a branch name from a slice of prefixes
1038fn branch_order(name: &str, order: &[Regex]) -> usize {
1039    order
1040        .iter()
1041        .position(|b| (name.starts_with(ORIGIN) && b.is_match(&name[7..])) || b.is_match(name))
1042        .unwrap_or(order.len())
1043}
1044
1045/// Finds the svg color for a branch name.
1046fn branch_color<T: Clone>(
1047    name: &str,
1048    order: &[(Regex, Vec<T>)],
1049    unknown: &[T],
1050    counter: usize,
1051) -> T {
1052    let stripped_name = name.strip_prefix(ORIGIN).unwrap_or(name);
1053
1054    for (regex, colors) in order {
1055        if regex.is_match(stripped_name) {
1056            return colors[counter % colors.len()].clone();
1057        }
1058    }
1059
1060    unknown[counter % unknown.len()].clone()
1061}
1062
1063/// Tries to extract the name of a merged-in branch from the merge commit summary.
1064pub fn parse_merge_summary(summary: &str, patterns: &MergePatterns) -> Option<String> {
1065    for regex in &patterns.patterns {
1066        if let Some(captures) = regex.captures(summary) {
1067            if captures.len() == 2 && captures.get(1).is_some() {
1068                return captures.get(1).map(|m| m.as_str().to_string());
1069            }
1070        }
1071    }
1072    None
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use crate::settings::MergePatterns;
1078
1079    #[test]
1080    fn parse_merge_summary() {
1081        let patterns = MergePatterns::default();
1082
1083        let gitlab_pull = "Merge branch 'feature/my-feature' into 'master'";
1084        let git_default = "Merge branch 'feature/my-feature' into dev";
1085        let git_master = "Merge branch 'feature/my-feature'";
1086        let github_pull = "Merge pull request #1 from user-x/feature/my-feature";
1087        let github_pull_2 = "Merge branch 'feature/my-feature' of github.com:user-x/repo";
1088        let bitbucket_pull = "Merged in feature/my-feature (pull request #1)";
1089
1090        assert_eq!(
1091            super::parse_merge_summary(gitlab_pull, &patterns),
1092            Some("feature/my-feature".to_string()),
1093        );
1094        assert_eq!(
1095            super::parse_merge_summary(git_default, &patterns),
1096            Some("feature/my-feature".to_string()),
1097        );
1098        assert_eq!(
1099            super::parse_merge_summary(git_master, &patterns),
1100            Some("feature/my-feature".to_string()),
1101        );
1102        assert_eq!(
1103            super::parse_merge_summary(github_pull, &patterns),
1104            Some("feature/my-feature".to_string()),
1105        );
1106        assert_eq!(
1107            super::parse_merge_summary(github_pull_2, &patterns),
1108            Some("feature/my-feature".to_string()),
1109        );
1110        assert_eq!(
1111            super::parse_merge_summary(bitbucket_pull, &patterns),
1112            Some("feature/my-feature".to_string()),
1113        );
1114    }
1115}