1use collections::{BTreeMap, HashMap};
2use git::{
3 BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
4 parse_git_remote_url,
5 repository::{CommitDiff, InitialGraphCommitData, LogOrder, LogSource},
6};
7use git_ui::commit_tooltip::CommitAvatar;
8use gpui::{
9 AnyElement, App, Bounds, ClipboardItem, Context, Corner, DefiniteLength, ElementId, Entity,
10 EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, ParentElement,
11 PathBuilder, Pixels, Point, Render, ScrollWheelEvent, SharedString, Styled, Subscription, Task,
12 WeakEntity, Window, actions, anchored, deferred, point, px,
13};
14use project::{
15 Project,
16 git_store::{CommitDataState, GitStoreEvent, Repository, RepositoryEvent},
17};
18use settings::Settings;
19use smallvec::{SmallVec, smallvec};
20use std::{ops::Range, rc::Rc, sync::Arc, sync::OnceLock};
21use theme::{AccentColors, ThemeSettings};
22use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
23use ui::{ContextMenu, ScrollableHandle, Table, TableInteractionState, Tooltip, prelude::*};
24use workspace::{
25 Workspace,
26 item::{Item, ItemEvent, SerializableItem},
27};
28
29const COMMIT_CIRCLE_RADIUS: Pixels = px(4.5);
30const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
31const LANE_WIDTH: Pixels = px(16.0);
32const LEFT_PADDING: Pixels = px(12.0);
33const LINE_WIDTH: Pixels = px(1.5);
34
35actions!(
36 git_graph,
37 [
38 /// Opens the Git Graph panel.
39 Open,
40 /// Opens the commit view for the selected commit.
41 OpenCommitView,
42 ]
43);
44
45fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
46 static FORMAT: OnceLock<Vec<BorrowedFormatItem<'static>>> = OnceLock::new();
47 FORMAT.get_or_init(|| {
48 time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
49 .unwrap_or_default()
50 })
51}
52
53fn format_timestamp(timestamp: i64) -> String {
54 let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
55 return "Unknown".to_string();
56 };
57
58 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
59 let local_datetime = datetime.to_offset(local_offset);
60
61 local_datetime
62 .format(timestamp_format())
63 .unwrap_or_default()
64}
65
66fn accent_colors_count(accents: &AccentColors) -> usize {
67 accents.0.len()
68}
69
70#[derive(Copy, Clone, Debug)]
71struct BranchColor(u8);
72
73#[derive(Debug)]
74enum LaneState {
75 Empty,
76 Active {
77 child: Oid,
78 parent: Oid,
79 color: Option<BranchColor>,
80 starting_row: usize,
81 starting_col: usize,
82 destination_column: Option<usize>,
83 segments: SmallVec<[CommitLineSegment; 1]>,
84 },
85}
86
87impl LaneState {
88 fn to_commit_lines(
89 &mut self,
90 ending_row: usize,
91 lane_column: usize,
92 parent_column: usize,
93 parent_color: BranchColor,
94 ) -> Option<CommitLine> {
95 let state = std::mem::replace(self, LaneState::Empty);
96
97 match state {
98 LaneState::Active {
99 #[cfg_attr(not(test), allow(unused_variables))]
100 parent,
101 #[cfg_attr(not(test), allow(unused_variables))]
102 child,
103 color,
104 starting_row,
105 starting_col,
106 destination_column,
107 mut segments,
108 } => {
109 let final_destination = destination_column.unwrap_or(parent_column);
110 let final_color = color.unwrap_or(parent_color);
111
112 Some(CommitLine {
113 #[cfg(test)]
114 child,
115 #[cfg(test)]
116 parent,
117 child_column: starting_col,
118 full_interval: starting_row..ending_row,
119 color_idx: final_color.0 as usize,
120 segments: {
121 match segments.last_mut() {
122 Some(CommitLineSegment::Straight { to_row })
123 if *to_row == usize::MAX =>
124 {
125 if final_destination != lane_column {
126 *to_row = ending_row - 1;
127
128 let curved_line = CommitLineSegment::Curve {
129 to_column: final_destination,
130 on_row: ending_row,
131 curve_kind: CurveKind::Checkout,
132 };
133
134 if *to_row == starting_row {
135 let last_index = segments.len() - 1;
136 segments[last_index] = curved_line;
137 } else {
138 segments.push(curved_line);
139 }
140 } else {
141 *to_row = ending_row;
142 }
143 }
144 Some(CommitLineSegment::Curve {
145 on_row,
146 to_column,
147 curve_kind,
148 }) if *on_row == usize::MAX => {
149 if *to_column == usize::MAX {
150 *to_column = final_destination;
151 }
152 if matches!(curve_kind, CurveKind::Merge) {
153 *on_row = starting_row + 1;
154 if *on_row < ending_row {
155 if *to_column != final_destination {
156 segments.push(CommitLineSegment::Straight {
157 to_row: ending_row - 1,
158 });
159 segments.push(CommitLineSegment::Curve {
160 to_column: final_destination,
161 on_row: ending_row,
162 curve_kind: CurveKind::Checkout,
163 });
164 } else {
165 segments.push(CommitLineSegment::Straight {
166 to_row: ending_row,
167 });
168 }
169 } else if *to_column != final_destination {
170 segments.push(CommitLineSegment::Curve {
171 to_column: final_destination,
172 on_row: ending_row,
173 curve_kind: CurveKind::Checkout,
174 });
175 }
176 } else {
177 *on_row = ending_row;
178 if *to_column != final_destination {
179 segments.push(CommitLineSegment::Straight {
180 to_row: ending_row,
181 });
182 segments.push(CommitLineSegment::Curve {
183 to_column: final_destination,
184 on_row: ending_row,
185 curve_kind: CurveKind::Checkout,
186 });
187 }
188 }
189 }
190 Some(CommitLineSegment::Curve {
191 on_row, to_column, ..
192 }) => {
193 if *on_row < ending_row {
194 if *to_column != final_destination {
195 segments.push(CommitLineSegment::Straight {
196 to_row: ending_row - 1,
197 });
198 segments.push(CommitLineSegment::Curve {
199 to_column: final_destination,
200 on_row: ending_row,
201 curve_kind: CurveKind::Checkout,
202 });
203 } else {
204 segments.push(CommitLineSegment::Straight {
205 to_row: ending_row,
206 });
207 }
208 } else if *to_column != final_destination {
209 segments.push(CommitLineSegment::Curve {
210 to_column: final_destination,
211 on_row: ending_row,
212 curve_kind: CurveKind::Checkout,
213 });
214 }
215 }
216 _ => {}
217 }
218
219 segments
220 },
221 })
222 }
223 LaneState::Empty => None,
224 }
225 }
226
227 fn is_empty(&self) -> bool {
228 match self {
229 LaneState::Empty => true,
230 LaneState::Active { .. } => false,
231 }
232 }
233}
234
235struct CommitEntry {
236 data: Arc<InitialGraphCommitData>,
237 lane: usize,
238 color_idx: usize,
239}
240
241type ActiveLaneIdx = usize;
242
243enum AllCommitCount {
244 NotLoaded,
245 Loaded(usize),
246}
247
248#[derive(Debug)]
249enum CurveKind {
250 Merge,
251 Checkout,
252}
253
254#[derive(Debug)]
255enum CommitLineSegment {
256 Straight {
257 to_row: usize,
258 },
259 Curve {
260 to_column: usize,
261 on_row: usize,
262 curve_kind: CurveKind,
263 },
264}
265
266#[derive(Debug)]
267struct CommitLine {
268 #[cfg(test)]
269 child: Oid,
270 #[cfg(test)]
271 parent: Oid,
272 child_column: usize,
273 full_interval: Range<usize>,
274 color_idx: usize,
275 segments: SmallVec<[CommitLineSegment; 1]>,
276}
277
278impl CommitLine {
279 fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> {
280 if first_visible_row > self.full_interval.end {
281 return None;
282 } else if first_visible_row <= self.full_interval.start {
283 return Some((0, self.child_column));
284 }
285
286 let mut current_column = self.child_column;
287
288 for (idx, segment) in self.segments.iter().enumerate() {
289 match segment {
290 CommitLineSegment::Straight { to_row } => {
291 if *to_row >= first_visible_row {
292 return Some((idx, current_column));
293 }
294 }
295 CommitLineSegment::Curve {
296 to_column, on_row, ..
297 } => {
298 if *on_row >= first_visible_row {
299 return Some((idx, current_column));
300 }
301 current_column = *to_column;
302 }
303 }
304 }
305
306 None
307 }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
311struct CommitLineKey {
312 child: Oid,
313 parent: Oid,
314}
315
316struct GraphData {
317 lane_states: SmallVec<[LaneState; 8]>,
318 lane_colors: HashMap<ActiveLaneIdx, BranchColor>,
319 parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>>,
320 next_color: BranchColor,
321 accent_colors_count: usize,
322 commits: Vec<Rc<CommitEntry>>,
323 max_commit_count: AllCommitCount,
324 max_lanes: usize,
325 lines: Vec<Rc<CommitLine>>,
326 active_commit_lines: HashMap<CommitLineKey, usize>,
327 active_commit_lines_by_parent: HashMap<Oid, SmallVec<[usize; 1]>>,
328}
329
330impl GraphData {
331 fn new(accent_colors_count: usize) -> Self {
332 GraphData {
333 lane_states: SmallVec::default(),
334 lane_colors: HashMap::default(),
335 parent_to_lanes: HashMap::default(),
336 next_color: BranchColor(0),
337 accent_colors_count,
338 commits: Vec::default(),
339 max_commit_count: AllCommitCount::NotLoaded,
340 max_lanes: 0,
341 lines: Vec::default(),
342 active_commit_lines: HashMap::default(),
343 active_commit_lines_by_parent: HashMap::default(),
344 }
345 }
346
347 fn clear(&mut self) {
348 self.lane_states.clear();
349 self.lane_colors.clear();
350 self.parent_to_lanes.clear();
351 self.commits.clear();
352 self.lines.clear();
353 self.active_commit_lines.clear();
354 self.active_commit_lines_by_parent.clear();
355 self.next_color = BranchColor(0);
356 self.max_commit_count = AllCommitCount::NotLoaded;
357 self.max_lanes = 0;
358 }
359
360 fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
361 self.lane_states
362 .iter()
363 .position(LaneState::is_empty)
364 .unwrap_or_else(|| {
365 self.lane_states.push(LaneState::Empty);
366 self.lane_states.len() - 1
367 })
368 }
369
370 fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor {
371 let accent_colors_count = self.accent_colors_count;
372 *self.lane_colors.entry(lane_idx).or_insert_with(|| {
373 let color_idx = self.next_color;
374 self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8);
375 color_idx
376 })
377 }
378
379 fn add_commits(&mut self, commits: &[Arc<InitialGraphCommitData>]) {
380 self.commits.reserve(commits.len());
381 self.lines.reserve(commits.len() / 2);
382
383 for commit in commits.iter() {
384 let commit_row = self.commits.len();
385
386 let commit_lane = self
387 .parent_to_lanes
388 .get(&commit.sha)
389 .and_then(|lanes| lanes.first().copied());
390
391 let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
392
393 let commit_color = self.get_lane_color(commit_lane);
394
395 if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) {
396 for lane_column in lanes {
397 let state = &mut self.lane_states[lane_column];
398
399 if let LaneState::Active {
400 starting_row,
401 segments,
402 ..
403 } = state
404 {
405 if let Some(CommitLineSegment::Curve {
406 to_column,
407 curve_kind: CurveKind::Merge,
408 ..
409 }) = segments.first_mut()
410 {
411 let curve_row = *starting_row + 1;
412 let would_overlap =
413 if lane_column != commit_lane && curve_row < commit_row {
414 self.commits[curve_row..commit_row]
415 .iter()
416 .any(|c| c.lane == commit_lane)
417 } else {
418 false
419 };
420
421 if would_overlap {
422 *to_column = lane_column;
423 }
424 }
425 }
426
427 if let Some(commit_line) =
428 state.to_commit_lines(commit_row, lane_column, commit_lane, commit_color)
429 {
430 self.lines.push(Rc::new(commit_line));
431 }
432 }
433 }
434
435 commit
436 .parents
437 .iter()
438 .enumerate()
439 .for_each(|(parent_idx, parent)| {
440 if parent_idx == 0 {
441 self.lane_states[commit_lane] = LaneState::Active {
442 parent: *parent,
443 child: commit.sha,
444 color: Some(commit_color),
445 starting_col: commit_lane,
446 starting_row: commit_row,
447 destination_column: None,
448 segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }],
449 };
450
451 self.parent_to_lanes
452 .entry(*parent)
453 .or_default()
454 .push(commit_lane);
455 } else {
456 let new_lane = self.first_empty_lane_idx();
457
458 self.lane_states[new_lane] = LaneState::Active {
459 parent: *parent,
460 child: commit.sha,
461 color: None,
462 starting_col: commit_lane,
463 starting_row: commit_row,
464 destination_column: None,
465 segments: smallvec![CommitLineSegment::Curve {
466 to_column: usize::MAX,
467 on_row: usize::MAX,
468 curve_kind: CurveKind::Merge,
469 },],
470 };
471
472 self.parent_to_lanes
473 .entry(*parent)
474 .or_default()
475 .push(new_lane);
476 }
477 });
478
479 self.max_lanes = self.max_lanes.max(self.lane_states.len());
480
481 self.commits.push(Rc::new(CommitEntry {
482 data: commit.clone(),
483 lane: commit_lane,
484 color_idx: commit_color.0 as usize,
485 }));
486 }
487 }
488}
489
490pub fn init(cx: &mut App) {
491 workspace::register_serializable_item::<GitGraph>(cx);
492
493 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
494 workspace.register_action_renderer(|div, workspace, _, cx| {
495 div.when(
496 workspace.project().read(cx).active_repository(cx).is_some(),
497 |div| {
498 let workspace = workspace.weak_handle();
499
500 div.on_action(move |_: &Open, window, cx| {
501 workspace
502 .update(cx, |workspace, cx| {
503 let project = workspace.project().clone();
504 let git_graph = cx.new(|cx| GitGraph::new(project, window, cx));
505 workspace.add_item_to_active_pane(
506 Box::new(git_graph),
507 None,
508 true,
509 window,
510 cx,
511 );
512 })
513 .ok();
514 })
515 },
516 )
517 });
518 })
519 .detach();
520}
521
522fn lane_center_x(bounds: Bounds<Pixels>, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels {
523 bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset
524}
525
526fn to_row_center(
527 to_row: usize,
528 row_height: Pixels,
529 scroll_offset: Pixels,
530 bounds: Bounds<Pixels>,
531) -> Pixels {
532 bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset
533}
534
535fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
536 let radius = COMMIT_CIRCLE_RADIUS;
537 let stroke_width = COMMIT_CIRCLE_STROKE_WIDTH;
538
539 let mut builder = PathBuilder::stroke(stroke_width);
540
541 // Start at the rightmost point of the circle
542 builder.move_to(point(center_x + radius, center_y));
543
544 // Draw the circle using two arc_to calls (top half, then bottom half)
545 builder.arc_to(
546 point(radius, radius),
547 px(0.),
548 false,
549 true,
550 point(center_x - radius, center_y),
551 );
552 builder.arc_to(
553 point(radius, radius),
554 px(0.),
555 false,
556 true,
557 point(center_x + radius, center_y),
558 );
559 builder.close();
560
561 if let Ok(path) = builder.build() {
562 window.paint_path(path, color);
563 }
564}
565
566pub struct GitGraph {
567 focus_handle: FocusHandle,
568 graph_data: GraphData,
569 project: Entity<Project>,
570 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
571 row_height: Pixels,
572 table_interaction_state: Entity<TableInteractionState>,
573 horizontal_scroll_offset: Pixels,
574 graph_viewport_width: Pixels,
575 selected_entry_idx: Option<usize>,
576 log_source: LogSource,
577 log_order: LogOrder,
578 selected_commit_diff: Option<CommitDiff>,
579 _commit_diff_task: Option<Task<()>>,
580 _load_task: Option<Task<()>>,
581}
582
583impl GitGraph {
584 fn row_height(cx: &App) -> Pixels {
585 let settings = ThemeSettings::get_global(cx);
586 let font_size = settings.buffer_font_size(cx);
587 font_size + px(12.0)
588 }
589
590 fn graph_content_width(&self) -> Pixels {
591 (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0
592 }
593
594 pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
595 let focus_handle = cx.focus_handle();
596 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
597 .detach();
598
599 let git_store = project.read(cx).git_store().clone();
600 let accent_colors = cx.theme().accents();
601 let mut graph = GraphData::new(accent_colors_count(accent_colors));
602 let log_source = LogSource::default();
603 let log_order = LogOrder::default();
604
605 cx.subscribe(&git_store, |this, _, event, cx| match event {
606 GitStoreEvent::RepositoryUpdated(_, repo_event, is_active) => {
607 if *is_active {
608 if let Some(repository) = this.project.read(cx).active_repository(cx) {
609 this.on_repository_event(repository, repo_event, cx);
610 }
611 }
612 }
613 GitStoreEvent::ActiveRepositoryChanged(_) => {
614 this.graph_data.clear();
615 cx.notify();
616 }
617 _ => {}
618 })
619 .detach();
620
621 if let Some(repository) = project.read(cx).active_repository(cx) {
622 repository.update(cx, |repository, cx| {
623 let commits =
624 repository.graph_data(log_source.clone(), log_order, 0..usize::MAX, cx);
625 graph.add_commits(commits);
626 });
627 }
628
629 let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
630 let mut row_height = Self::row_height(cx);
631
632 cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
633 let new_row_height = Self::row_height(cx);
634 if new_row_height != row_height {
635 this.row_height = new_row_height;
636 this.table_interaction_state.update(cx, |state, _cx| {
637 state.scroll_handle.0.borrow_mut().last_item_size = None;
638 });
639 row_height = new_row_height;
640 }
641 cx.notify();
642 })
643 .detach();
644
645 GitGraph {
646 focus_handle,
647 project,
648 graph_data: graph,
649 _load_task: None,
650 _commit_diff_task: None,
651 context_menu: None,
652 row_height,
653 table_interaction_state,
654 horizontal_scroll_offset: px(0.),
655 graph_viewport_width: px(88.),
656 selected_entry_idx: None,
657 selected_commit_diff: None,
658 log_source,
659 log_order,
660 }
661 }
662
663 fn on_repository_event(
664 &mut self,
665 repository: Entity<Repository>,
666 event: &RepositoryEvent,
667 cx: &mut Context<Self>,
668 ) {
669 match event {
670 RepositoryEvent::GitGraphCountUpdated(_, commit_count) => {
671 let old_count = self.graph_data.commits.len();
672
673 repository.update(cx, |repository, cx| {
674 let commits = repository.graph_data(
675 self.log_source.clone(),
676 self.log_order,
677 old_count..*commit_count,
678 cx,
679 );
680 self.graph_data.add_commits(commits);
681 });
682
683 self.graph_data.max_commit_count = AllCommitCount::Loaded(*commit_count);
684 }
685 RepositoryEvent::BranchChanged => {
686 self.graph_data.clear();
687 cx.notify();
688 }
689 _ => {}
690 }
691 }
692
693 fn render_badge(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
694 div()
695 .px_1p5()
696 .py_0p5()
697 .h(self.row_height - px(4.0))
698 .flex()
699 .items_center()
700 .justify_center()
701 .rounded_md()
702 .bg(accent_color.opacity(0.18))
703 .border_1()
704 .border_color(accent_color.opacity(0.55))
705 .child(
706 Label::new(name.clone())
707 .size(LabelSize::Small)
708 .color(Color::Default)
709 .single_line(),
710 )
711 }
712
713 fn render_table_rows(
714 &mut self,
715 range: Range<usize>,
716 _window: &mut Window,
717 cx: &mut Context<Self>,
718 ) -> Vec<Vec<AnyElement>> {
719 let repository = self
720 .project
721 .read_with(cx, |project, cx| project.active_repository(cx));
722
723 let row_height = self.row_height;
724
725 // We fetch data outside the visible viewport to avoid loading entries when
726 // users scroll through the git graph
727 if let Some(repository) = repository.as_ref() {
728 const FETCH_RANGE: usize = 100;
729 repository.update(cx, |repository, cx| {
730 self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
731 ..(range.end + FETCH_RANGE)
732 .min(self.graph_data.commits.len().saturating_sub(1))]
733 .iter()
734 .for_each(|commit| {
735 repository.fetch_commit_data(commit.data.sha, cx);
736 });
737 });
738 }
739
740 range
741 .map(|idx| {
742 let Some((commit, repository)) =
743 self.graph_data.commits.get(idx).zip(repository.as_ref())
744 else {
745 return vec![
746 div().h(row_height).into_any_element(),
747 div().h(row_height).into_any_element(),
748 div().h(row_height).into_any_element(),
749 div().h(row_height).into_any_element(),
750 ];
751 };
752
753 let data = repository.update(cx, |repository, cx| {
754 repository.fetch_commit_data(commit.data.sha, cx).clone()
755 });
756
757 let short_sha = commit.data.sha.display_short();
758 let mut formatted_time = String::new();
759 let subject;
760 let author_name;
761
762 if let CommitDataState::Loaded(data) = data {
763 subject = data.subject.clone();
764 author_name = data.author_name.clone();
765 formatted_time = format_timestamp(data.commit_timestamp);
766 } else {
767 subject = "Loading...".into();
768 author_name = "".into();
769 }
770
771 let accent_colors = cx.theme().accents();
772 let accent_color = accent_colors
773 .0
774 .get(commit.color_idx)
775 .copied()
776 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
777 let is_selected = self.selected_entry_idx == Some(idx);
778 let text_color = if is_selected {
779 Color::Default
780 } else {
781 Color::Muted
782 };
783
784 vec![
785 div()
786 .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
787 .overflow_hidden()
788 .tooltip(Tooltip::text(subject.clone()))
789 .child(
790 h_flex()
791 .gap_1()
792 .items_center()
793 .overflow_hidden()
794 .children((!commit.data.ref_names.is_empty()).then(|| {
795 h_flex().flex_shrink().gap_2().items_center().children(
796 commit
797 .data
798 .ref_names
799 .iter()
800 .map(|name| self.render_badge(name, accent_color)),
801 )
802 }))
803 .child(
804 Label::new(subject)
805 .color(text_color)
806 .truncate()
807 .single_line(),
808 ),
809 )
810 .into_any_element(),
811 Label::new(formatted_time)
812 .color(text_color)
813 .single_line()
814 .into_any_element(),
815 Label::new(author_name)
816 .color(text_color)
817 .single_line()
818 .into_any_element(),
819 Label::new(short_sha)
820 .color(text_color)
821 .single_line()
822 .into_any_element(),
823 ]
824 })
825 .collect()
826 }
827
828 fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
829 if self.selected_entry_idx == Some(idx) {
830 return;
831 }
832
833 self.selected_entry_idx = Some(idx);
834 self.selected_commit_diff = None;
835
836 let Some(commit) = self.graph_data.commits.get(idx) else {
837 return;
838 };
839
840 let sha = commit.data.sha.to_string();
841 let repository = self
842 .project
843 .read_with(cx, |project, cx| project.active_repository(cx));
844
845 let Some(repository) = repository else {
846 return;
847 };
848
849 let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
850
851 self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
852 if let Ok(Ok(diff)) = diff_receiver.await {
853 this.update(cx, |this, cx| {
854 this.selected_commit_diff = Some(diff);
855 cx.notify();
856 })
857 .ok();
858 }
859 }));
860
861 cx.notify();
862 }
863
864 fn get_remote(
865 &self,
866 repository: &Repository,
867 _window: &mut Window,
868 cx: &mut App,
869 ) -> Option<GitRemote> {
870 let remote_url = repository.default_remote_url()?;
871 let provider_registry = GitHostingProviderRegistry::default_global(cx);
872 let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
873 Some(GitRemote {
874 host: provider,
875 owner: parsed.owner.into(),
876 repo: parsed.repo.into(),
877 })
878 }
879
880 fn render_commit_detail_panel(
881 &self,
882 window: &mut Window,
883 cx: &mut Context<Self>,
884 ) -> impl IntoElement {
885 let Some(selected_idx) = self.selected_entry_idx else {
886 return div().into_any_element();
887 };
888
889 let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
890 return div().into_any_element();
891 };
892
893 let repository = self
894 .project
895 .read_with(cx, |project, cx| project.active_repository(cx));
896
897 let Some(repository) = repository else {
898 return div().into_any_element();
899 };
900
901 let data = repository.update(cx, |repository, cx| {
902 repository
903 .fetch_commit_data(commit_entry.data.sha, cx)
904 .clone()
905 });
906
907 let full_sha: SharedString = commit_entry.data.sha.to_string().into();
908 let truncated_sha: SharedString = {
909 let sha_str = full_sha.as_ref();
910 if sha_str.len() > 24 {
911 format!("{}...", &sha_str[..24]).into()
912 } else {
913 full_sha.clone()
914 }
915 };
916 let ref_names = commit_entry.data.ref_names.clone();
917 let accent_colors = cx.theme().accents();
918 let accent_color = accent_colors
919 .0
920 .get(commit_entry.color_idx)
921 .copied()
922 .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
923
924 let (author_name, author_email, commit_timestamp, subject) = match &data {
925 CommitDataState::Loaded(data) => (
926 data.author_name.clone(),
927 data.author_email.clone(),
928 Some(data.commit_timestamp),
929 data.subject.clone(),
930 ),
931 CommitDataState::Loading => ("Loading...".into(), "".into(), None, "Loading...".into()),
932 };
933
934 let date_string = commit_timestamp
935 .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
936 .map(|datetime| {
937 let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
938 let local_datetime = datetime.to_offset(local_offset);
939 let format =
940 time::format_description::parse("[month repr:short] [day], [year]").ok();
941 format
942 .and_then(|f| local_datetime.format(&f).ok())
943 .unwrap_or_default()
944 })
945 .unwrap_or_default();
946
947 let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
948
949 let avatar = {
950 let avatar = CommitAvatar::new(&full_sha, remote.as_ref());
951 v_flex()
952 .w(px(64.))
953 .h(px(64.))
954 .border_1()
955 .border_color(cx.theme().colors().border)
956 .rounded_full()
957 .justify_center()
958 .items_center()
959 .child(
960 avatar
961 .avatar(window, cx)
962 .map(|a| a.size(px(64.)).into_any_element())
963 .unwrap_or_else(|| {
964 Icon::new(IconName::Person)
965 .color(Color::Muted)
966 .size(IconSize::XLarge)
967 .into_any_element()
968 }),
969 )
970 };
971
972 let changed_files_count = self
973 .selected_commit_diff
974 .as_ref()
975 .map(|diff| diff.files.len())
976 .unwrap_or(0);
977
978 v_flex()
979 .w(px(300.))
980 .h_full()
981 .border_l_1()
982 .border_color(cx.theme().colors().border)
983 .bg(cx.theme().colors().surface_background)
984 .child(
985 v_flex()
986 .p_3()
987 .gap_3()
988 .child(
989 h_flex().justify_between().child(avatar).child(
990 IconButton::new("close-detail", IconName::Close)
991 .icon_size(IconSize::Small)
992 .on_click(cx.listener(move |this, _, _, cx| {
993 this.selected_entry_idx = None;
994 this.selected_commit_diff = None;
995 this._commit_diff_task = None;
996 cx.notify();
997 })),
998 ),
999 )
1000 .child(
1001 v_flex()
1002 .gap_0p5()
1003 .child(Label::new(author_name.clone()).weight(FontWeight::SEMIBOLD))
1004 .child(
1005 Label::new(date_string)
1006 .color(Color::Muted)
1007 .size(LabelSize::Small),
1008 ),
1009 )
1010 .children((!ref_names.is_empty()).then(|| {
1011 h_flex().gap_1().flex_wrap().children(
1012 ref_names
1013 .iter()
1014 .map(|name| self.render_badge(name, accent_color)),
1015 )
1016 }))
1017 .child(
1018 v_flex()
1019 .gap_1p5()
1020 .child(
1021 h_flex()
1022 .gap_1()
1023 .child(
1024 Icon::new(IconName::Person)
1025 .size(IconSize::Small)
1026 .color(Color::Muted),
1027 )
1028 .child(
1029 Label::new(author_name)
1030 .size(LabelSize::Small)
1031 .color(Color::Muted),
1032 )
1033 .when(!author_email.is_empty(), |this| {
1034 this.child(
1035 Label::new(format!("<{}>", author_email))
1036 .size(LabelSize::Small)
1037 .color(Color::Ignored),
1038 )
1039 }),
1040 )
1041 .child(
1042 h_flex()
1043 .gap_1()
1044 .child(
1045 Icon::new(IconName::Hash)
1046 .size(IconSize::Small)
1047 .color(Color::Muted),
1048 )
1049 .child({
1050 let copy_sha = full_sha.clone();
1051 Button::new("sha-button", truncated_sha)
1052 .style(ButtonStyle::Transparent)
1053 .label_size(LabelSize::Small)
1054 .color(Color::Muted)
1055 .tooltip(Tooltip::text(format!(
1056 "Copy SHA: {}",
1057 copy_sha
1058 )))
1059 .on_click(move |_, _, cx| {
1060 cx.write_to_clipboard(ClipboardItem::new_string(
1061 copy_sha.to_string(),
1062 ));
1063 })
1064 }),
1065 )
1066 .when_some(remote.clone(), |this, remote| {
1067 let provider_name = remote.host.name();
1068 let icon = match provider_name.as_str() {
1069 "GitHub" => IconName::Github,
1070 _ => IconName::Link,
1071 };
1072 let parsed_remote = ParsedGitRemote {
1073 owner: remote.owner.as_ref().into(),
1074 repo: remote.repo.as_ref().into(),
1075 };
1076 let params = BuildCommitPermalinkParams {
1077 sha: full_sha.as_ref(),
1078 };
1079 let url = remote
1080 .host
1081 .build_commit_permalink(&parsed_remote, params)
1082 .to_string();
1083 this.child(
1084 h_flex()
1085 .gap_1()
1086 .child(
1087 Icon::new(icon)
1088 .size(IconSize::Small)
1089 .color(Color::Muted),
1090 )
1091 .child(
1092 Button::new(
1093 "view-on-provider",
1094 format!("View on {}", provider_name),
1095 )
1096 .style(ButtonStyle::Transparent)
1097 .label_size(LabelSize::Small)
1098 .color(Color::Muted)
1099 .on_click(
1100 move |_, _, cx| {
1101 cx.open_url(&url);
1102 },
1103 ),
1104 ),
1105 )
1106 }),
1107 ),
1108 )
1109 .child(
1110 div()
1111 .border_t_1()
1112 .border_color(cx.theme().colors().border)
1113 .p_3()
1114 .child(
1115 v_flex()
1116 .gap_2()
1117 .child(Label::new(subject).weight(FontWeight::MEDIUM)),
1118 ),
1119 )
1120 .child(
1121 div()
1122 .flex_1()
1123 .overflow_hidden()
1124 .border_t_1()
1125 .border_color(cx.theme().colors().border)
1126 .p_3()
1127 .child(
1128 v_flex()
1129 .gap_2()
1130 .child(
1131 Label::new(format!("{} Changed Files", changed_files_count))
1132 .size(LabelSize::Small)
1133 .color(Color::Muted),
1134 )
1135 .children(self.selected_commit_diff.as_ref().map(|diff| {
1136 v_flex().gap_1().children(diff.files.iter().map(|file| {
1137 let file_name: String = file
1138 .path
1139 .file_name()
1140 .map(|n| n.to_string())
1141 .unwrap_or_default();
1142 let dir_path: String = file
1143 .path
1144 .parent()
1145 .map(|p| p.as_unix_str().to_string())
1146 .unwrap_or_default();
1147
1148 h_flex()
1149 .gap_1()
1150 .overflow_hidden()
1151 .child(
1152 Icon::new(IconName::File)
1153 .size(IconSize::Small)
1154 .color(Color::Accent),
1155 )
1156 .child(
1157 Label::new(file_name)
1158 .size(LabelSize::Small)
1159 .single_line(),
1160 )
1161 .when(!dir_path.is_empty(), |this| {
1162 this.child(
1163 Label::new(dir_path)
1164 .size(LabelSize::Small)
1165 .color(Color::Muted)
1166 .single_line(),
1167 )
1168 })
1169 }))
1170 })),
1171 ),
1172 )
1173 .into_any_element()
1174 }
1175
1176 pub fn render_graph(&self, cx: &mut Context<GitGraph>) -> impl IntoElement {
1177 let row_height = self.row_height;
1178 let table_state = self.table_interaction_state.read(cx);
1179 let viewport_height = table_state
1180 .scroll_handle
1181 .0
1182 .borrow()
1183 .last_item_size
1184 .map(|size| size.item.height)
1185 .unwrap_or(px(600.0));
1186 let loaded_commit_count = self.graph_data.commits.len();
1187
1188 let content_height = row_height * loaded_commit_count;
1189 let max_scroll = (content_height - viewport_height).max(px(0.));
1190 let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
1191
1192 let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
1193 let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
1194 let horizontal_scroll_offset = self.horizontal_scroll_offset;
1195
1196 let max_lanes = self.graph_data.max_lanes.max(6);
1197 let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1198 let last_visible_row =
1199 first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
1200
1201 let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
1202 ..(last_visible_row).min(loaded_commit_count);
1203 let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
1204 let commit_lines: Vec<_> = self
1205 .graph_data
1206 .lines
1207 .iter()
1208 .filter(|line| {
1209 line.full_interval.start <= viewport_range.end
1210 && line.full_interval.end >= viewport_range.start
1211 })
1212 .cloned()
1213 .collect();
1214
1215 let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
1216
1217 gpui::canvas(
1218 move |_bounds, _window, _cx| {},
1219 move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
1220 window.paint_layer(bounds, |window| {
1221 let accent_colors = cx.theme().accents();
1222
1223 for (row_idx, row) in rows.into_iter().enumerate() {
1224 let row_color = accent_colors.color_for_index(row.color_idx as u32);
1225 let row_y_center =
1226 bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
1227 - vertical_scroll_offset;
1228
1229 let commit_x =
1230 lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
1231
1232 draw_commit_circle(commit_x, row_y_center, row_color, window);
1233 }
1234
1235 for line in commit_lines {
1236 let Some((start_segment_idx, start_column)) =
1237 line.get_first_visible_segment_idx(first_visible_row)
1238 else {
1239 continue;
1240 };
1241
1242 let line_x =
1243 lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
1244
1245 let start_row = line.full_interval.start as i32 - first_visible_row as i32;
1246
1247 let from_y =
1248 bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
1249 - vertical_scroll_offset
1250 + COMMIT_CIRCLE_RADIUS;
1251
1252 let mut current_row = from_y;
1253 let mut current_column = line_x;
1254
1255 let mut builder = PathBuilder::stroke(LINE_WIDTH);
1256 builder.move_to(point(line_x, from_y));
1257
1258 let segments = &line.segments[start_segment_idx..];
1259
1260 for (segment_idx, segment) in segments.iter().enumerate() {
1261 let is_last = segment_idx + 1 == segments.len();
1262
1263 match segment {
1264 CommitLineSegment::Straight { to_row } => {
1265 let mut dest_row = to_row_center(
1266 to_row - first_visible_row,
1267 row_height,
1268 vertical_scroll_offset,
1269 bounds,
1270 );
1271 if is_last {
1272 dest_row -= COMMIT_CIRCLE_RADIUS;
1273 }
1274
1275 let dest_point = point(current_column, dest_row);
1276
1277 current_row = dest_point.y;
1278 builder.line_to(dest_point);
1279 builder.move_to(dest_point);
1280 }
1281 CommitLineSegment::Curve {
1282 to_column,
1283 on_row,
1284 curve_kind,
1285 } => {
1286 let mut to_column = lane_center_x(
1287 bounds,
1288 *to_column as f32,
1289 horizontal_scroll_offset,
1290 );
1291
1292 let mut to_row = to_row_center(
1293 *on_row - first_visible_row,
1294 row_height,
1295 vertical_scroll_offset,
1296 bounds,
1297 );
1298
1299 // This means that this branch was a checkout
1300 let going_right = to_column > current_column;
1301 let column_shift = if going_right {
1302 COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
1303 } else {
1304 -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
1305 };
1306
1307 let control = match curve_kind {
1308 CurveKind::Checkout => {
1309 if is_last {
1310 to_column -= column_shift;
1311 }
1312 builder.move_to(point(current_column, current_row));
1313 point(current_column, to_row)
1314 }
1315 CurveKind::Merge => {
1316 if is_last {
1317 to_row -= COMMIT_CIRCLE_RADIUS;
1318 }
1319 builder.move_to(point(
1320 current_column + column_shift,
1321 current_row - COMMIT_CIRCLE_RADIUS,
1322 ));
1323 point(to_column, current_row)
1324 }
1325 };
1326
1327 match curve_kind {
1328 CurveKind::Checkout
1329 if (to_row - current_row).abs() > row_height =>
1330 {
1331 let start_curve =
1332 point(current_column, current_row + row_height);
1333 builder.line_to(start_curve);
1334 builder.move_to(start_curve);
1335 }
1336 CurveKind::Merge
1337 if (to_column - current_column).abs() > LANE_WIDTH =>
1338 {
1339 let column_shift =
1340 if going_right { LANE_WIDTH } else { -LANE_WIDTH };
1341
1342 let start_curve = point(
1343 current_column + column_shift,
1344 current_row - COMMIT_CIRCLE_RADIUS,
1345 );
1346
1347 builder.line_to(start_curve);
1348 builder.move_to(start_curve);
1349 }
1350 _ => {}
1351 };
1352
1353 builder.curve_to(point(to_column, to_row), control);
1354 current_row = to_row;
1355 current_column = to_column;
1356 builder.move_to(point(current_column, current_row));
1357 }
1358 }
1359 }
1360
1361 builder.close();
1362 lines.entry(line.color_idx).or_default().push(builder);
1363 }
1364
1365 for (color_idx, builders) in lines {
1366 let line_color = accent_colors.color_for_index(color_idx as u32);
1367
1368 for builder in builders {
1369 if let Ok(path) = builder.build() {
1370 // we paint each color on it's own layer to stop overlapping lines
1371 // of different colors changing the color of a line
1372 window.paint_layer(bounds, |window| {
1373 window.paint_path(path, line_color);
1374 });
1375 }
1376 }
1377 }
1378 })
1379 },
1380 )
1381 .w(graph_width)
1382 .h_full()
1383 }
1384
1385 fn handle_graph_scroll(
1386 &mut self,
1387 event: &ScrollWheelEvent,
1388 window: &mut Window,
1389 cx: &mut Context<Self>,
1390 ) {
1391 let line_height = window.line_height();
1392 let delta = event.delta.pixel_delta(line_height);
1393
1394 let table_state = self.table_interaction_state.read(cx);
1395 let current_offset = table_state.scroll_offset();
1396
1397 let viewport_height = table_state.scroll_handle.viewport().size.height;
1398
1399 let commit_count = match self.graph_data.max_commit_count {
1400 AllCommitCount::Loaded(count) => count,
1401 AllCommitCount::NotLoaded => self.graph_data.commits.len(),
1402 };
1403 let content_height = self.row_height * commit_count;
1404 let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
1405
1406 let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
1407 let new_offset = Point::new(current_offset.x, new_y);
1408
1409 let max_lanes = self.graph_data.max_lanes.max(1);
1410 let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
1411 let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
1412
1413 let new_horizontal_offset =
1414 (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
1415
1416 let vertical_changed = new_offset != current_offset;
1417 let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
1418
1419 if vertical_changed {
1420 table_state.set_scroll_offset(new_offset);
1421 }
1422
1423 if horizontal_changed {
1424 self.horizontal_scroll_offset = new_horizontal_offset;
1425 }
1426
1427 if vertical_changed || horizontal_changed {
1428 cx.notify();
1429 }
1430 }
1431}
1432
1433impl Render for GitGraph {
1434 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1435 let description_width_fraction = 0.72;
1436 let date_width_fraction = 0.12;
1437 let author_width_fraction = 0.10;
1438 let commit_width_fraction = 0.06;
1439
1440 let commit_count = match self.graph_data.max_commit_count {
1441 AllCommitCount::Loaded(count) => count,
1442 AllCommitCount::NotLoaded => {
1443 self.project.update(cx, |project, cx| {
1444 if let Some(repository) = project.active_repository(cx) {
1445 repository.update(cx, |repository, cx| {
1446 // Start loading the graph data if we haven't started already
1447 repository.graph_data(
1448 self.log_source.clone(),
1449 self.log_order,
1450 0..0,
1451 cx,
1452 );
1453 })
1454 }
1455 });
1456
1457 self.graph_data.commits.len()
1458 }
1459 };
1460
1461 let content = if self.graph_data.commits.is_empty() {
1462 let message = "No commits found";
1463 div()
1464 .size_full()
1465 .flex()
1466 .items_center()
1467 .justify_center()
1468 .child(Label::new(message).color(Color::Muted))
1469 } else {
1470 div()
1471 .size_full()
1472 .flex()
1473 .flex_row()
1474 .child(
1475 div()
1476 .w(self.graph_content_width())
1477 .h_full()
1478 .flex()
1479 .flex_col()
1480 .child(
1481 div()
1482 .p_2()
1483 .border_b_1()
1484 .border_color(cx.theme().colors().border)
1485 .child(Label::new("Graph").color(Color::Muted)),
1486 )
1487 .child(
1488 div()
1489 .id("graph-canvas")
1490 .flex_1()
1491 .overflow_hidden()
1492 .child(self.render_graph(cx))
1493 .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)),
1494 ),
1495 )
1496 .child({
1497 let row_height = self.row_height;
1498 let selected_entry_idx = self.selected_entry_idx;
1499 let weak_self = cx.weak_entity();
1500 div().flex_1().size_full().child(
1501 Table::new(4)
1502 .interactable(&self.table_interaction_state)
1503 .hide_row_borders()
1504 .header(vec![
1505 Label::new("Description")
1506 .color(Color::Muted)
1507 .into_any_element(),
1508 Label::new("Date").color(Color::Muted).into_any_element(),
1509 Label::new("Author").color(Color::Muted).into_any_element(),
1510 Label::new("Commit").color(Color::Muted).into_any_element(),
1511 ])
1512 .column_widths(
1513 [
1514 DefiniteLength::Fraction(description_width_fraction),
1515 DefiniteLength::Fraction(date_width_fraction),
1516 DefiniteLength::Fraction(author_width_fraction),
1517 DefiniteLength::Fraction(commit_width_fraction),
1518 ]
1519 .to_vec(),
1520 )
1521 .map_row(move |(index, row), _window, cx| {
1522 let is_selected = selected_entry_idx == Some(index);
1523 let weak = weak_self.clone();
1524 row.h(row_height)
1525 .when(is_selected, |row| {
1526 row.bg(cx.theme().colors().element_selected)
1527 })
1528 .on_click(move |_, _, cx| {
1529 weak.update(cx, |this, cx| {
1530 this.select_entry(index, cx);
1531 })
1532 .ok();
1533 })
1534 .into_any_element()
1535 })
1536 .uniform_list(
1537 "git-graph-commits",
1538 commit_count,
1539 cx.processor(Self::render_table_rows),
1540 ),
1541 )
1542 })
1543 .when(self.selected_entry_idx.is_some(), |this| {
1544 this.child(self.render_commit_detail_panel(window, cx))
1545 })
1546 };
1547
1548 div()
1549 .size_full()
1550 .bg(cx.theme().colors().editor_background)
1551 .key_context("GitGraph")
1552 .track_focus(&self.focus_handle)
1553 .child(content)
1554 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1555 deferred(
1556 anchored()
1557 .position(*position)
1558 .anchor(Corner::TopLeft)
1559 .child(menu.clone()),
1560 )
1561 .with_priority(1)
1562 }))
1563 }
1564}
1565
1566impl EventEmitter<ItemEvent> for GitGraph {}
1567
1568impl Focusable for GitGraph {
1569 fn focus_handle(&self, _cx: &App) -> FocusHandle {
1570 self.focus_handle.clone()
1571 }
1572}
1573
1574impl Item for GitGraph {
1575 type Event = ItemEvent;
1576
1577 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1578 "Git Graph".into()
1579 }
1580
1581 fn show_toolbar(&self) -> bool {
1582 false
1583 }
1584
1585 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
1586 f(*event)
1587 }
1588}
1589
1590impl SerializableItem for GitGraph {
1591 fn serialized_item_kind() -> &'static str {
1592 "GitGraph"
1593 }
1594
1595 fn cleanup(
1596 workspace_id: workspace::WorkspaceId,
1597 alive_items: Vec<workspace::ItemId>,
1598 _window: &mut Window,
1599 cx: &mut App,
1600 ) -> Task<gpui::Result<()>> {
1601 workspace::delete_unloaded_items(
1602 alive_items,
1603 workspace_id,
1604 "git_graphs",
1605 &persistence::GIT_GRAPHS,
1606 cx,
1607 )
1608 }
1609
1610 fn deserialize(
1611 project: Entity<Project>,
1612 _: WeakEntity<Workspace>,
1613 workspace_id: workspace::WorkspaceId,
1614 item_id: workspace::ItemId,
1615 window: &mut Window,
1616 cx: &mut App,
1617 ) -> Task<gpui::Result<Entity<Self>>> {
1618 if persistence::GIT_GRAPHS
1619 .get_git_graph(item_id, workspace_id)
1620 .ok()
1621 .is_some_and(|is_open| is_open)
1622 {
1623 let git_graph = cx.new(|cx| GitGraph::new(project, window, cx));
1624 Task::ready(Ok(git_graph))
1625 } else {
1626 Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
1627 }
1628 }
1629
1630 fn serialize(
1631 &mut self,
1632 workspace: &mut Workspace,
1633 item_id: workspace::ItemId,
1634 _closing: bool,
1635 _window: &mut Window,
1636 cx: &mut Context<Self>,
1637 ) -> Option<Task<gpui::Result<()>>> {
1638 let workspace_id = workspace.database_id()?;
1639 Some(cx.background_spawn(async move {
1640 persistence::GIT_GRAPHS
1641 .save_git_graph(item_id, workspace_id, true)
1642 .await
1643 }))
1644 }
1645
1646 fn should_serialize(&self, event: &Self::Event) -> bool {
1647 event == &ItemEvent::UpdateTab
1648 }
1649}
1650
1651mod persistence {
1652 use db::{
1653 query,
1654 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
1655 sqlez_macros::sql,
1656 };
1657 use workspace::WorkspaceDb;
1658
1659 pub struct GitGraphsDb(ThreadSafeConnection);
1660
1661 impl Domain for GitGraphsDb {
1662 const NAME: &str = stringify!(GitGraphsDb);
1663
1664 const MIGRATIONS: &[&str] = (&[sql!(
1665 CREATE TABLE git_graphs (
1666 workspace_id INTEGER,
1667 item_id INTEGER UNIQUE,
1668 is_open INTEGER DEFAULT FALSE,
1669
1670 PRIMARY KEY(workspace_id, item_id),
1671 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
1672 ON DELETE CASCADE
1673 ) STRICT;
1674 )]);
1675 }
1676
1677 db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]);
1678
1679 impl GitGraphsDb {
1680 query! {
1681 pub async fn save_git_graph(
1682 item_id: workspace::ItemId,
1683 workspace_id: workspace::WorkspaceId,
1684 is_open: bool
1685 ) -> Result<()> {
1686 INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
1687 VALUES (?, ?, ?)
1688 }
1689 }
1690
1691 query! {
1692 pub fn get_git_graph(
1693 item_id: workspace::ItemId,
1694 workspace_id: workspace::WorkspaceId
1695 ) -> Result<bool> {
1696 SELECT is_open
1697 FROM git_graphs
1698 WHERE item_id = ? AND workspace_id = ?
1699 }
1700 }
1701 }
1702}
1703
1704#[cfg(test)]
1705mod tests {
1706 use super::*;
1707 use anyhow::{Context, Result, bail};
1708 use collections::{HashMap, HashSet};
1709 use fs::FakeFs;
1710 use git::Oid;
1711 use git::repository::InitialGraphCommitData;
1712 use gpui::TestAppContext;
1713 use project::Project;
1714 use rand::prelude::*;
1715 use serde_json::json;
1716 use settings::SettingsStore;
1717 use smallvec::{SmallVec, smallvec};
1718 use std::path::Path;
1719 use std::sync::Arc;
1720
1721 fn init_test(cx: &mut TestAppContext) {
1722 cx.update(|cx| {
1723 let settings_store = SettingsStore::test(cx);
1724 cx.set_global(settings_store);
1725 });
1726 }
1727
1728 /// Generates a random commit DAG suitable for testing git graph rendering.
1729 ///
1730 /// The commits are ordered newest-first (like git log output), so:
1731 /// - Index 0 = most recent commit (HEAD)
1732 /// - Last index = oldest commit (root, has no parents)
1733 /// - Parents of commit at index I must have index > I
1734 ///
1735 /// When `adversarial` is true, generates complex topologies with many branches
1736 /// and octopus merges. Otherwise generates more realistic linear histories
1737 /// with occasional branches.
1738 fn generate_random_commit_dag(
1739 rng: &mut StdRng,
1740 num_commits: usize,
1741 adversarial: bool,
1742 ) -> Vec<Arc<InitialGraphCommitData>> {
1743 if num_commits == 0 {
1744 return Vec::new();
1745 }
1746
1747 let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
1748 let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
1749
1750 for i in 0..num_commits {
1751 let sha = oids[i];
1752
1753 let parents = if i == num_commits - 1 {
1754 smallvec![]
1755 } else {
1756 generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
1757 };
1758
1759 let ref_names = if i == 0 {
1760 vec!["HEAD".into(), "main".into()]
1761 } else if adversarial && rng.random_bool(0.1) {
1762 vec![format!("branch-{}", i).into()]
1763 } else {
1764 Vec::new()
1765 };
1766
1767 commits.push(Arc::new(InitialGraphCommitData {
1768 sha,
1769 parents,
1770 ref_names,
1771 }));
1772 }
1773
1774 commits
1775 }
1776
1777 fn generate_parents_from_oids(
1778 rng: &mut StdRng,
1779 oids: &[Oid],
1780 current_idx: usize,
1781 num_commits: usize,
1782 adversarial: bool,
1783 ) -> SmallVec<[Oid; 1]> {
1784 let remaining = num_commits - current_idx - 1;
1785 if remaining == 0 {
1786 return smallvec![];
1787 }
1788
1789 if adversarial {
1790 let merge_chance = 0.4;
1791 let octopus_chance = 0.15;
1792
1793 if remaining >= 3 && rng.random_bool(octopus_chance) {
1794 let num_parents = rng.random_range(3..=remaining.min(5));
1795 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
1796 parent_indices.shuffle(rng);
1797 parent_indices
1798 .into_iter()
1799 .take(num_parents)
1800 .map(|idx| oids[idx])
1801 .collect()
1802 } else if remaining >= 2 && rng.random_bool(merge_chance) {
1803 let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
1804 parent_indices.shuffle(rng);
1805 parent_indices
1806 .into_iter()
1807 .take(2)
1808 .map(|idx| oids[idx])
1809 .collect()
1810 } else {
1811 let parent_idx = rng.random_range(current_idx + 1..num_commits);
1812 smallvec![oids[parent_idx]]
1813 }
1814 } else {
1815 let merge_chance = 0.15;
1816 let skip_chance = 0.1;
1817
1818 if remaining >= 2 && rng.random_bool(merge_chance) {
1819 let first_parent = current_idx + 1;
1820 let second_parent = rng.random_range(current_idx + 2..num_commits);
1821 smallvec![oids[first_parent], oids[second_parent]]
1822 } else if rng.random_bool(skip_chance) && remaining >= 2 {
1823 let skip = rng.random_range(1..remaining.min(3));
1824 smallvec![oids[current_idx + 1 + skip]]
1825 } else {
1826 smallvec![oids[current_idx + 1]]
1827 }
1828 }
1829 }
1830
1831 fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
1832 graph
1833 .commits
1834 .iter()
1835 .enumerate()
1836 .map(|(idx, entry)| (entry.data.sha, idx))
1837 .collect()
1838 }
1839
1840 fn verify_commit_order(
1841 graph: &GraphData,
1842 commits: &[Arc<InitialGraphCommitData>],
1843 ) -> Result<()> {
1844 if graph.commits.len() != commits.len() {
1845 bail!(
1846 "Commit count mismatch: graph has {} commits, expected {}",
1847 graph.commits.len(),
1848 commits.len()
1849 );
1850 }
1851
1852 for (idx, (graph_commit, expected_commit)) in
1853 graph.commits.iter().zip(commits.iter()).enumerate()
1854 {
1855 if graph_commit.data.sha != expected_commit.sha {
1856 bail!(
1857 "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
1858 idx,
1859 graph_commit.data.sha,
1860 expected_commit.sha
1861 );
1862 }
1863 }
1864
1865 Ok(())
1866 }
1867
1868 fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
1869 for line in &graph.lines {
1870 let child_row = *oid_to_row
1871 .get(&line.child)
1872 .context("Line references non-existent child commit")?;
1873
1874 let parent_row = *oid_to_row
1875 .get(&line.parent)
1876 .context("Line references non-existent parent commit")?;
1877
1878 if child_row >= parent_row {
1879 bail!(
1880 "child_row ({}) must be < parent_row ({})",
1881 child_row,
1882 parent_row
1883 );
1884 }
1885
1886 if line.full_interval.start != child_row {
1887 bail!(
1888 "full_interval.start ({}) != child_row ({})",
1889 line.full_interval.start,
1890 child_row
1891 );
1892 }
1893
1894 if line.full_interval.end != parent_row {
1895 bail!(
1896 "full_interval.end ({}) != parent_row ({})",
1897 line.full_interval.end,
1898 parent_row
1899 );
1900 }
1901
1902 if let Some(last_segment) = line.segments.last() {
1903 let segment_end_row = match last_segment {
1904 CommitLineSegment::Straight { to_row } => *to_row,
1905 CommitLineSegment::Curve { on_row, .. } => *on_row,
1906 };
1907
1908 if segment_end_row != line.full_interval.end {
1909 bail!(
1910 "last segment ends at row {} but full_interval.end is {}",
1911 segment_end_row,
1912 line.full_interval.end
1913 );
1914 }
1915 }
1916 }
1917
1918 Ok(())
1919 }
1920
1921 fn verify_column_correctness(
1922 graph: &GraphData,
1923 oid_to_row: &HashMap<Oid, usize>,
1924 ) -> Result<()> {
1925 for line in &graph.lines {
1926 let child_row = *oid_to_row
1927 .get(&line.child)
1928 .context("Line references non-existent child commit")?;
1929
1930 let parent_row = *oid_to_row
1931 .get(&line.parent)
1932 .context("Line references non-existent parent commit")?;
1933
1934 let child_lane = graph.commits[child_row].lane;
1935 if line.child_column != child_lane {
1936 bail!(
1937 "child_column ({}) != child's lane ({})",
1938 line.child_column,
1939 child_lane
1940 );
1941 }
1942
1943 let mut current_column = line.child_column;
1944 for segment in &line.segments {
1945 if let CommitLineSegment::Curve { to_column, .. } = segment {
1946 current_column = *to_column;
1947 }
1948 }
1949
1950 let parent_lane = graph.commits[parent_row].lane;
1951 if current_column != parent_lane {
1952 bail!(
1953 "ending column ({}) != parent's lane ({})",
1954 current_column,
1955 parent_lane
1956 );
1957 }
1958 }
1959
1960 Ok(())
1961 }
1962
1963 fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
1964 for line in &graph.lines {
1965 if line.segments.is_empty() {
1966 bail!("Line has no segments");
1967 }
1968
1969 let mut current_row = line.full_interval.start;
1970
1971 for (idx, segment) in line.segments.iter().enumerate() {
1972 let segment_end_row = match segment {
1973 CommitLineSegment::Straight { to_row } => *to_row,
1974 CommitLineSegment::Curve { on_row, .. } => *on_row,
1975 };
1976
1977 if segment_end_row < current_row {
1978 bail!(
1979 "segment {} ends at row {} which is before current row {}",
1980 idx,
1981 segment_end_row,
1982 current_row
1983 );
1984 }
1985
1986 current_row = segment_end_row;
1987 }
1988 }
1989
1990 Ok(())
1991 }
1992
1993 fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
1994 for line in &graph.lines {
1995 let child_row = line.full_interval.start;
1996
1997 let mut current_column = line.child_column;
1998 let mut current_row = child_row;
1999
2000 for segment in &line.segments {
2001 match segment {
2002 CommitLineSegment::Straight { to_row } => {
2003 for row in (current_row + 1)..*to_row {
2004 if row < graph.commits.len() {
2005 let commit_at_row = &graph.commits[row];
2006 if commit_at_row.lane == current_column {
2007 bail!(
2008 "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
2009 current_row,
2010 to_row,
2011 current_column,
2012 commit_at_row.data.sha,
2013 row
2014 );
2015 }
2016 }
2017 }
2018 current_row = *to_row;
2019 }
2020 CommitLineSegment::Curve {
2021 to_column, on_row, ..
2022 } => {
2023 current_column = *to_column;
2024 current_row = *on_row;
2025 }
2026 }
2027 }
2028 }
2029
2030 Ok(())
2031 }
2032
2033 fn verify_coverage(graph: &GraphData) -> Result<()> {
2034 let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
2035 for entry in &graph.commits {
2036 for parent in &entry.data.parents {
2037 expected_edges.insert((entry.data.sha, *parent));
2038 }
2039 }
2040
2041 let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
2042 for line in &graph.lines {
2043 let edge = (line.child, line.parent);
2044
2045 if !found_edges.insert(edge) {
2046 bail!(
2047 "Duplicate line found for edge {:?} -> {:?}",
2048 line.child,
2049 line.parent
2050 );
2051 }
2052
2053 if !expected_edges.contains(&edge) {
2054 bail!(
2055 "Orphan line found: {:?} -> {:?} is not in the commit graph",
2056 line.child,
2057 line.parent
2058 );
2059 }
2060 }
2061
2062 for (child, parent) in &expected_edges {
2063 if !found_edges.contains(&(*child, *parent)) {
2064 bail!("Missing line for edge {:?} -> {:?}", child, parent);
2065 }
2066 }
2067
2068 assert_eq!(
2069 expected_edges.symmetric_difference(&found_edges).count(),
2070 0,
2071 "The symmetric difference should be zero"
2072 );
2073
2074 Ok(())
2075 }
2076
2077 fn verify_merge_line_optimality(
2078 graph: &GraphData,
2079 oid_to_row: &HashMap<Oid, usize>,
2080 ) -> Result<()> {
2081 for line in &graph.lines {
2082 let first_segment = line.segments.first();
2083 let is_merge_line = matches!(
2084 first_segment,
2085 Some(CommitLineSegment::Curve {
2086 curve_kind: CurveKind::Merge,
2087 ..
2088 })
2089 );
2090
2091 if !is_merge_line {
2092 continue;
2093 }
2094
2095 let child_row = *oid_to_row
2096 .get(&line.child)
2097 .context("Line references non-existent child commit")?;
2098
2099 let parent_row = *oid_to_row
2100 .get(&line.parent)
2101 .context("Line references non-existent parent commit")?;
2102
2103 let parent_lane = graph.commits[parent_row].lane;
2104
2105 let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
2106 continue;
2107 };
2108
2109 let curves_directly_to_parent = *to_column == parent_lane;
2110
2111 if !curves_directly_to_parent {
2112 continue;
2113 }
2114
2115 let curve_row = child_row + 1;
2116 let has_commits_in_path = graph.commits[curve_row..parent_row]
2117 .iter()
2118 .any(|c| c.lane == parent_lane);
2119
2120 if has_commits_in_path {
2121 bail!(
2122 "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
2123 line.child,
2124 line.parent,
2125 parent_lane,
2126 curve_row,
2127 parent_row
2128 );
2129 }
2130
2131 let curve_ends_at_parent = curve_row == parent_row;
2132
2133 if curve_ends_at_parent {
2134 if line.segments.len() != 1 {
2135 bail!(
2136 "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
2137 line.child,
2138 line.parent,
2139 line.segments.len()
2140 );
2141 }
2142 } else {
2143 if line.segments.len() != 2 {
2144 bail!(
2145 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
2146 line.child,
2147 line.parent,
2148 line.segments.len()
2149 );
2150 }
2151
2152 let is_straight_segment = matches!(
2153 line.segments.get(1),
2154 Some(CommitLineSegment::Straight { .. })
2155 );
2156
2157 if !is_straight_segment {
2158 bail!(
2159 "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
2160 line.child,
2161 line.parent
2162 );
2163 }
2164 }
2165 }
2166
2167 Ok(())
2168 }
2169
2170 fn verify_all_invariants(
2171 graph: &GraphData,
2172 commits: &[Arc<InitialGraphCommitData>],
2173 ) -> Result<()> {
2174 let oid_to_row = build_oid_to_row_map(graph);
2175
2176 verify_commit_order(graph, commits).context("commit order")?;
2177 verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
2178 verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
2179 verify_segment_continuity(graph).context("segment continuity")?;
2180 verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
2181 verify_coverage(graph).context("coverage")?;
2182 verify_line_overlaps(graph).context("line overlaps")?;
2183 Ok(())
2184 }
2185
2186 #[test]
2187 fn test_git_graph_merge_commits() {
2188 let mut rng = StdRng::seed_from_u64(42);
2189
2190 let oid1 = Oid::random(&mut rng);
2191 let oid2 = Oid::random(&mut rng);
2192 let oid3 = Oid::random(&mut rng);
2193 let oid4 = Oid::random(&mut rng);
2194
2195 let commits = vec![
2196 Arc::new(InitialGraphCommitData {
2197 sha: oid1,
2198 parents: smallvec![oid2, oid3],
2199 ref_names: vec!["HEAD".into()],
2200 }),
2201 Arc::new(InitialGraphCommitData {
2202 sha: oid2,
2203 parents: smallvec![oid4],
2204 ref_names: vec![],
2205 }),
2206 Arc::new(InitialGraphCommitData {
2207 sha: oid3,
2208 parents: smallvec![oid4],
2209 ref_names: vec![],
2210 }),
2211 Arc::new(InitialGraphCommitData {
2212 sha: oid4,
2213 parents: smallvec![],
2214 ref_names: vec![],
2215 }),
2216 ];
2217
2218 let mut graph_data = GraphData::new(8);
2219 graph_data.add_commits(&commits);
2220
2221 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2222 panic!("Graph invariant violation for merge commits:\n{}", error);
2223 }
2224 }
2225
2226 #[test]
2227 fn test_git_graph_linear_commits() {
2228 let mut rng = StdRng::seed_from_u64(42);
2229
2230 let oid1 = Oid::random(&mut rng);
2231 let oid2 = Oid::random(&mut rng);
2232 let oid3 = Oid::random(&mut rng);
2233
2234 let commits = vec![
2235 Arc::new(InitialGraphCommitData {
2236 sha: oid1,
2237 parents: smallvec![oid2],
2238 ref_names: vec!["HEAD".into()],
2239 }),
2240 Arc::new(InitialGraphCommitData {
2241 sha: oid2,
2242 parents: smallvec![oid3],
2243 ref_names: vec![],
2244 }),
2245 Arc::new(InitialGraphCommitData {
2246 sha: oid3,
2247 parents: smallvec![],
2248 ref_names: vec![],
2249 }),
2250 ];
2251
2252 let mut graph_data = GraphData::new(8);
2253 graph_data.add_commits(&commits);
2254
2255 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2256 panic!("Graph invariant violation for linear commits:\n{}", error);
2257 }
2258 }
2259
2260 #[test]
2261 fn test_git_graph_random_commits() {
2262 for seed in 0..100 {
2263 let mut rng = StdRng::seed_from_u64(seed);
2264
2265 let adversarial = rng.random_bool(0.2);
2266 let num_commits = if adversarial {
2267 rng.random_range(10..100)
2268 } else {
2269 rng.random_range(5..50)
2270 };
2271
2272 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
2273
2274 assert_eq!(
2275 num_commits,
2276 commits.len(),
2277 "seed={}: Generate random commit dag didn't generate the correct amount of commits",
2278 seed
2279 );
2280
2281 let mut graph_data = GraphData::new(8);
2282 graph_data.add_commits(&commits);
2283
2284 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2285 panic!(
2286 "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
2287 seed, adversarial, num_commits, error
2288 );
2289 }
2290 }
2291 }
2292
2293 // The full integration test has less iterations because it's significantly slower
2294 // than the random commit test
2295 #[gpui::test(iterations = 5)]
2296 async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
2297 init_test(cx);
2298
2299 let adversarial = rng.random_bool(0.2);
2300 let num_commits = if adversarial {
2301 rng.random_range(10..100)
2302 } else {
2303 rng.random_range(5..50)
2304 };
2305
2306 let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
2307
2308 let fs = FakeFs::new(cx.executor());
2309 fs.insert_tree(
2310 Path::new("/project"),
2311 json!({
2312 ".git": {},
2313 "file.txt": "content",
2314 }),
2315 )
2316 .await;
2317
2318 fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
2319
2320 let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
2321 cx.run_until_parked();
2322
2323 let repository = project.read_with(cx, |project, cx| {
2324 project
2325 .active_repository(cx)
2326 .expect("should have a repository")
2327 });
2328
2329 repository.update(cx, |repo, cx| {
2330 repo.graph_data(
2331 crate::LogSource::default(),
2332 crate::LogOrder::default(),
2333 0..usize::MAX,
2334 cx,
2335 );
2336 });
2337 cx.run_until_parked();
2338
2339 let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
2340 repo.graph_data(
2341 crate::LogSource::default(),
2342 crate::LogOrder::default(),
2343 0..usize::MAX,
2344 cx,
2345 )
2346 .to_vec()
2347 });
2348
2349 let mut graph_data = GraphData::new(8);
2350 graph_data.add_commits(&graph_commits);
2351
2352 if let Err(error) = verify_all_invariants(&graph_data, &commits) {
2353 panic!(
2354 "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
2355 adversarial, num_commits, error
2356 );
2357 }
2358 }
2359}