Skip to main content

mermaid_text/
git_graph.rs

1//! Data model for Mermaid `gitGraph` diagrams.
2//!
3//! A git graph diagram represents a commit history across one or more branches,
4//! rendered as a timeline flowing top-to-bottom with branch lanes as columns.
5//!
6//! Example source:
7//!
8//! ```text
9//! gitGraph
10//!     commit
11//!     commit id: "second"
12//!     branch develop
13//!     checkout develop
14//!     commit
15//!     commit id: "feature-x"
16//!     checkout main
17//!     merge develop
18//!     commit tag: "v1.0"
19//! ```
20//!
21//! Constructed by [`crate::parser::git_graph::parse`] and consumed by
22//! [`crate::render::git_graph::render`].
23
24/// The kind of a commit in a git graph.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CommitKind {
27    /// An ordinary commit (glyph: `*`).
28    Normal,
29    /// A merge commit with two parents (glyph: `M`).
30    Merge,
31    /// A cherry-picked commit copied from another branch (glyph: `C`).
32    CherryPick,
33}
34
35/// A single commit on a branch in the git graph.
36///
37/// `id` is the display identifier (auto-generated as `c0`, `c1`, … when the
38/// source omits `id: "..."`). `branch` is the name of the branch this commit
39/// lives on. `tag` is an optional label rendered next to the commit in `[...]`.
40/// `parent` is the index into `GitGraph::commits` of the preceding commit on
41/// the same branch (or `None` for the initial commit of `main`). `merge_parent`
42/// is only set for `Merge` commits and points to the HEAD of the branch being
43/// merged in.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Commit {
46    /// Short display id (e.g. `"c0"`, `"second"`, `"feature-x"`).
47    pub id: String,
48    /// Name of the branch this commit belongs to.
49    pub branch: String,
50    /// Optional release / annotation tag rendered as `[tag]`.
51    pub tag: Option<String>,
52    /// Classification of this commit.
53    pub kind: CommitKind,
54    /// Index into `GitGraph::commits` of the direct parent (same-branch
55    /// predecessor). `None` only for the very first commit of `main`.
56    pub parent: Option<usize>,
57    /// Index into `GitGraph::commits` of the merge-source HEAD.
58    /// Only set when `kind == CommitKind::Merge`.
59    pub merge_parent: Option<usize>,
60}
61
62/// A branch in the git graph.
63///
64/// `name` is the branch name. `created_after_commit` is the index into
65/// `GitGraph::commits` of the commit that was HEAD on the parent branch when
66/// this branch was created via `branch <name>`. It is `None` only for `main`,
67/// which always exists from the start.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Branch {
70    pub name: String,
71    /// The commit (by index) from which this branch was forked, or `None`
72    /// for `main` (the initial branch, which has no parent commit).
73    pub created_after_commit: Option<usize>,
74}
75
76/// A source-ordered event in the git timeline.
77///
78/// Replaying `events` in order re-creates the exact sequence of operations
79/// the author wrote, which the layout pass needs to position rows correctly.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum Event {
82    /// A commit was added (index into `GitGraph::commits`).
83    Commit(usize),
84    /// A new branch was created (index into `GitGraph::branches`).
85    BranchCreated(usize),
86    /// The active branch changed. Value is the branch name.
87    Checkout(String),
88    /// A merge was performed; the merge commit index is stored.
89    Merge(usize),
90    /// A cherry-pick was performed; the cherry-pick commit index is stored.
91    CherryPick(usize),
92}
93
94/// A parsed `gitGraph` diagram.
95///
96/// `branches` lists all branches in creation order (`main` always first).
97/// `commits` lists all commits in timeline order (the order they were emitted
98/// by the source). `events` is the source-ordered operation log used by the
99/// renderer to determine row ordering and glyph connections.
100///
101/// Constructed by [`crate::parser::git_graph::parse`] and consumed by
102/// [`crate::render::git_graph::render`].
103#[derive(Debug, Clone, PartialEq, Eq, Default)]
104pub struct GitGraph {
105    /// All branches in branch-creation order; `main` is always at index 0.
106    pub branches: Vec<Branch>,
107    /// All commits in timeline order (the order they appear in the source).
108    pub commits: Vec<Commit>,
109    /// Source-ordered event log for the layout pass to replay.
110    pub events: Vec<Event>,
111}
112
113impl GitGraph {
114    /// Return the lane index (0-based column) assigned to `branch_name`.
115    ///
116    /// Lanes are assigned in branch-creation order so `main` is always lane 0.
117    /// Returns `None` if the branch does not exist.
118    pub fn lane_of(&self, branch_name: &str) -> Option<usize> {
119        self.branches.iter().position(|b| b.name == branch_name)
120    }
121
122    /// Return the index of the most recent commit on `branch_name`, scanning
123    /// backwards through `commits` to find the last one on that branch.
124    ///
125    /// Returns `None` if no commit exists on the branch yet.
126    pub fn head_of(&self, branch_name: &str) -> Option<usize> {
127        self.commits
128            .iter()
129            .enumerate()
130            .rev()
131            .find(|(_, c)| c.branch == branch_name)
132            .map(|(i, _)| i)
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Tests
138// ---------------------------------------------------------------------------
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn make_graph() -> GitGraph {
145        let mut g = GitGraph {
146            branches: vec![
147                Branch {
148                    name: "main".to_string(),
149                    created_after_commit: None,
150                },
151                Branch {
152                    name: "develop".to_string(),
153                    created_after_commit: Some(1),
154                },
155            ],
156            ..Default::default()
157        };
158        // Commit 0: c0 on main, no parent
159        g.commits.push(Commit {
160            id: "c0".to_string(),
161            branch: "main".to_string(),
162            tag: None,
163            kind: CommitKind::Normal,
164            parent: None,
165            merge_parent: None,
166        });
167        // Commit 1: c1 on main
168        g.commits.push(Commit {
169            id: "c1".to_string(),
170            branch: "main".to_string(),
171            tag: None,
172            kind: CommitKind::Normal,
173            parent: Some(0),
174            merge_parent: None,
175        });
176        // Commit 2: c2 on develop, forked from c1
177        g.commits.push(Commit {
178            id: "c2".to_string(),
179            branch: "develop".to_string(),
180            tag: None,
181            kind: CommitKind::Normal,
182            parent: Some(1),
183            merge_parent: None,
184        });
185        // Commit 3: merge commit on main
186        g.commits.push(Commit {
187            id: "c3".to_string(),
188            branch: "main".to_string(),
189            tag: Some("v1.0".to_string()),
190            kind: CommitKind::Merge,
191            parent: Some(1),
192            merge_parent: Some(2),
193        });
194        g
195    }
196
197    // ---- (1) lane_of returns correct column indices -----------------------
198
199    #[test]
200    fn lane_of_returns_correct_indices() {
201        let g = make_graph();
202        assert_eq!(g.lane_of("main"), Some(0));
203        assert_eq!(g.lane_of("develop"), Some(1));
204        assert_eq!(g.lane_of("nonexistent"), None);
205    }
206
207    // ---- (2) head_of returns the last commit on a branch -----------------
208
209    #[test]
210    fn head_of_returns_latest_commit_on_branch() {
211        let g = make_graph();
212        // After the merge, the last commit on main is c3 (index 3).
213        assert_eq!(g.head_of("main"), Some(3));
214        // The last commit on develop is c2 (index 2).
215        assert_eq!(g.head_of("develop"), Some(2));
216        // Unknown branch returns None.
217        assert_eq!(g.head_of("feature"), None);
218    }
219
220    // ---- (3) merge commit has both parent indices set --------------------
221
222    #[test]
223    fn merge_commit_has_both_parents() {
224        let g = make_graph();
225        let merge = &g.commits[3];
226        assert_eq!(merge.kind, CommitKind::Merge);
227        assert_eq!(merge.parent, Some(1));
228        assert_eq!(merge.merge_parent, Some(2));
229        assert_eq!(merge.tag.as_deref(), Some("v1.0"));
230    }
231
232    // ---- (4) default graph is empty -------------------------------------
233
234    #[test]
235    fn default_graph_is_empty() {
236        let g = GitGraph::default();
237        assert!(g.branches.is_empty());
238        assert!(g.commits.is_empty());
239        assert!(g.events.is_empty());
240        assert_eq!(g.lane_of("main"), None);
241        assert_eq!(g.head_of("main"), None);
242    }
243
244    // ---- (5) commit kind variants are distinct --------------------------
245
246    #[test]
247    fn commit_kind_variants_are_distinct() {
248        assert_ne!(CommitKind::Normal, CommitKind::Merge);
249        assert_ne!(CommitKind::Merge, CommitKind::CherryPick);
250        assert_ne!(CommitKind::Normal, CommitKind::CherryPick);
251    }
252}