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}