git: Add graph support (#44434)

Anthony Eid , pyundev , Cole Miller , and Zed Zippy created

Closes #26866

### Summary
Adds a git graph to Zed, accessible via the `git_graph::Open` action if
a project has an active repository. There's still more to do, but this
is a solid foundation to expand upon. The code structure is in line with
Zed's codebase and shouldn't require architectural changes to add
missing features.

The git graph can be opened via the command palette (`git graph: open`)
or by binding a key to `git_graph::Open`. It's available when the
project has an active git repository.

### Architecture

Similar to the Debugger, the git graph is split between a data layer and
a view/UI layer. When the view layer is rendering, it queries the data
layer for its active state. This setup allows the data layer to lazily
request graph data (only when needed for rendering), abstracts collab
from the view layer, allows most of the data loading to happen on a
background thread, and makes caching easy to implement.

#### Graph Loading

The graph data is loaded in two phases:
1. `Repository::graph_data()` streams commit structure (SHA, parents,
refs) in chunks of 1000 via `git log`
2. `CommitDataReader` lazily fetches full commit details (author,
timestamp, subject) on-demand using a persistent `git cat-file --batch`
process

This two-phase approach makes the initial loading of the graph as fast
as possible, because `git log` takes significantly longer when all the
needed graph data is queried through it. Zed then lazily loads commits
in the user's viewport through `cat-file --batch`. This makes scrolling
to any place in the graph extremely snappy and benefits the
collaborative architecture by only fetching data needed to render the
graph. It also allows Zed to share commit data between different graph
visualizations (e.g., date order vs. topological order).

#### Performance

Tested on both the Zed and LLVM repositories with good performance in
both cases. The two-phase loading approach and lazy fetching keep the UI
responsive even with large commit histories.

#### Testing 

I added property testing that builds randomized commit graphs and
verifies that the graph is constructed correctly. This also works as an
integration test and will be expanded in the future to test collab graph
visualization, graph filtering, commit actions, etc.

### New Crate
- `git_graph` (GPL-licensed) — contains UI and graph computation logic

### Not Yet Implemented
- Remote repository support (collab)
- Filtering by branch
- Commit actions (checkout, cherry-pick, etc.)
- Search
- Open commit view for selected commit
- Resizable columns 
- Column filtering

#### Reference
<img width="1624" height="976" alt="Screenshot 2025-01-22 at 8 15 39 PM"
src="https://github.com/user-attachments/assets/0f10924a-3964-462f-b320-42d84d02f7bf"
/>

Special thanks to [Alberto Slavica](https://github.com/pyundev) for
submitting #44405, which was a good base to work off of.

Release Notes:

- git: Add initial version of git graph

---------

Co-authored-by: pyundev <pyundev@users.noreply.github.com>
Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

Cargo.lock                             |   25 
Cargo.toml                             |    2 
crates/fs/src/fake_git_repo.rs         |   33 
crates/fs/src/fs.rs                    |    9 
crates/git/Cargo.toml                  |    1 
crates/git/src/repository.rs           |  364 ++++
crates/git_graph/Cargo.toml            |   46 
crates/git_graph/LICENSE-GPL           |    1 
crates/git_graph/src/git_graph.rs      | 2359 ++++++++++++++++++++++++++++
crates/project/src/git_store.rs        |  249 ++
crates/ui/src/components/data_table.rs |   12 
crates/zed/Cargo.toml                  |    1 
crates/zed/src/main.rs                 |    1 
crates/zed/src/zed.rs                  |    2 
14 files changed, 3,098 insertions(+), 7 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7143,6 +7143,7 @@ dependencies = [
  "schemars",
  "serde",
  "serde_json",
+ "smallvec",
  "smol",
  "sum_tree",
  "tempfile",
@@ -7170,6 +7171,29 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "git_graph"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "db",
+ "fs",
+ "git",
+ "git_ui",
+ "gpui",
+ "project",
+ "rand 0.9.2",
+ "recent_projects",
+ "serde_json",
+ "settings",
+ "smallvec",
+ "theme",
+ "time",
+ "ui",
+ "workspace",
+]
+
 [[package]]
 name = "git_hosting_providers"
 version = "0.1.0"
@@ -20856,6 +20880,7 @@ dependencies = [
  "fs",
  "futures 0.3.31",
  "git",
+ "git_graph",
  "git_hosting_providers",
  "git_ui",
  "go_to_line",

Cargo.toml 🔗

@@ -79,6 +79,7 @@ members = [
     "crates/fsevent",
     "crates/fuzzy",
     "crates/git",
+    "crates/git_graph",
     "crates/git_hosting_providers",
     "crates/git_ui",
     "crates/go_to_line",
@@ -312,6 +313,7 @@ fs = { path = "crates/fs" }
 fsevent = { path = "crates/fsevent" }
 fuzzy = { path = "crates/fuzzy" }
 git = { path = "crates/git" }
+git_graph = { path = "crates/git_graph" }
 git_hosting_providers = { path = "crates/git_hosting_providers" }
 git_ui = { path = "crates/git_ui" }
 go_to_line = { path = "crates/go_to_line" }

crates/fs/src/fake_git_repo.rs 🔗

@@ -6,8 +6,9 @@ use git::{
     Oid, RunHook,
     blame::Blame,
     repository::{
-        AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
-        GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, Worktree,
+        AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions,
+        GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder,
+        LogSource, PushOptions, Remote, RepoPath, ResetMode, Worktree,
     },
     status::{
         DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
@@ -18,7 +19,7 @@ use gpui::{AsyncApp, BackgroundExecutor, SharedString, Task};
 use ignore::gitignore::GitignoreBuilder;
 use parking_lot::Mutex;
 use rope::Rope;
-use smol::future::FutureExt as _;
+use smol::{channel::Sender, future::FutureExt as _};
 use std::{path::PathBuf, sync::Arc};
 use text::LineEnding;
 use util::{paths::PathStyle, rel_path::RelPath};
@@ -49,6 +50,7 @@ pub struct FakeGitRepositoryState {
     pub remotes: HashMap<String, String>,
     pub simulated_index_write_error_message: Option<String>,
     pub refs: HashMap<String, String>,
+    pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
 }
 
 impl FakeGitRepositoryState {
@@ -66,6 +68,7 @@ impl FakeGitRepositoryState {
             merge_base_contents: Default::default(),
             oids: Default::default(),
             remotes: HashMap::default(),
+            graph_commits: Vec::new(),
         }
     }
 }
@@ -737,4 +740,28 @@ impl GitRepository for FakeGitRepository {
             Ok(())
         })
     }
+
+    fn initial_graph_data(
+        &self,
+        _log_source: LogSource,
+        _log_order: LogOrder,
+        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
+    ) -> BoxFuture<'_, Result<()>> {
+        let fs = self.fs.clone();
+        let dot_git_path = self.dot_git_path.clone();
+        async move {
+            let graph_commits =
+                fs.with_git_state(&dot_git_path, false, |state| state.graph_commits.clone())?;
+
+            for chunk in graph_commits.chunks(GRAPH_CHUNK_SIZE) {
+                request_tx.send(chunk.to_vec()).await.ok();
+            }
+            Ok(())
+        }
+        .boxed()
+    }
+
+    fn commit_data_reader(&self) -> Result<CommitDataReader> {
+        anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
+    }
 }

crates/fs/src/fs.rs 🔗

@@ -54,7 +54,7 @@ use collections::{BTreeMap, btree_map};
 use fake_git_repo::FakeGitRepositoryState;
 #[cfg(feature = "test-support")]
 use git::{
-    repository::{RepoPath, repo_path},
+    repository::{InitialGraphCommitData, RepoPath, repo_path},
     status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus},
 };
 
@@ -2001,6 +2001,13 @@ impl FakeFs {
         .unwrap();
     }
 
+    pub fn set_graph_commits(&self, dot_git: &Path, commits: Vec<Arc<InitialGraphCommitData>>) {
+        self.with_git_state(dot_git, true, |state| {
+            state.graph_commits = commits;
+        })
+        .unwrap();
+    }
+
     /// Put the given git repository into a state with the given status,
     /// by mutating the head, index, and unmerged state.
     pub fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&str, FileStatus)]) {

crates/git/Cargo.toml 🔗

@@ -31,6 +31,7 @@ rand = { workspace = true, optional = true }
 rope.workspace = true
 schemars.workspace = true
 serde.workspace = true
+smallvec.workspace = true
 smol.workspace = true
 sum_tree.workspace = true
 text.workspace = true

crates/git/src/repository.rs 🔗

@@ -4,6 +4,7 @@ use crate::status::{DiffTreeType, GitStatus, StatusCode, TreeDiff};
 use crate::{Oid, RunHook, SHORT_SHA_LENGTH};
 use anyhow::{Context as _, Result, anyhow, bail};
 use collections::HashMap;
+use futures::channel::oneshot;
 use futures::future::BoxFuture;
 use futures::io::BufWriter;
 use futures::{AsyncWriteExt, FutureExt as _, select_biased};
@@ -13,12 +14,15 @@ use parking_lot::Mutex;
 use rope::Rope;
 use schemars::JsonSchema;
 use serde::Deserialize;
+use smallvec::SmallVec;
+use smol::channel::Sender;
 use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
 use text::LineEnding;
 
 use std::collections::HashSet;
 use std::ffi::{OsStr, OsString};
 use std::process::{ExitStatus, Stdio};
+use std::str::FromStr;
 use std::{
     cmp::Ordering,
     future,
@@ -37,6 +41,106 @@ pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
 
 pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
 
+/// Format string used in graph log to get initial data for the git graph
+/// %H - Full commit hash
+/// %P - Parent hashes
+/// %D - Ref names
+/// %x00 - Null byte separator, used to split up commit data
+static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
+
+/// Number of commits to load per chunk for the git graph.
+pub const GRAPH_CHUNK_SIZE: usize = 1000;
+
+/// Commit data needed for the git graph visualization.
+#[derive(Debug, Clone)]
+pub struct GraphCommitData {
+    pub sha: Oid,
+    /// Most commits have a single parent, so we use a SmallVec to avoid allocations.
+    pub parents: SmallVec<[Oid; 1]>,
+    pub author_name: SharedString,
+    pub author_email: SharedString,
+    pub commit_timestamp: i64,
+    pub subject: SharedString,
+}
+
+#[derive(Debug)]
+pub struct InitialGraphCommitData {
+    pub sha: Oid,
+    pub parents: SmallVec<[Oid; 1]>,
+    pub ref_names: Vec<SharedString>,
+}
+
+struct CommitDataRequest {
+    sha: Oid,
+    response_tx: oneshot::Sender<Result<GraphCommitData>>,
+}
+
+pub struct CommitDataReader {
+    request_tx: smol::channel::Sender<CommitDataRequest>,
+    _task: Task<()>,
+}
+
+impl CommitDataReader {
+    pub async fn read(&self, sha: Oid) -> Result<GraphCommitData> {
+        let (response_tx, response_rx) = oneshot::channel();
+        self.request_tx
+            .send(CommitDataRequest { sha, response_tx })
+            .await
+            .map_err(|_| anyhow!("commit data reader task closed"))?;
+        response_rx
+            .await
+            .map_err(|_| anyhow!("commit data reader task dropped response"))?
+    }
+}
+
+fn parse_cat_file_commit(sha: Oid, content: &str) -> Option<GraphCommitData> {
+    let mut parents = SmallVec::new();
+    let mut author_name = SharedString::default();
+    let mut author_email = SharedString::default();
+    let mut commit_timestamp = 0i64;
+    let mut in_headers = true;
+    let mut subject = None;
+
+    for line in content.lines() {
+        if in_headers {
+            if line.is_empty() {
+                in_headers = false;
+                continue;
+            }
+
+            if let Some(parent_sha) = line.strip_prefix("parent ") {
+                if let Ok(oid) = Oid::from_str(parent_sha.trim()) {
+                    parents.push(oid);
+                }
+            } else if let Some(author_line) = line.strip_prefix("author ") {
+                if let Some((name_email, _timestamp_tz)) = author_line.rsplit_once(' ') {
+                    if let Some((name_email, timestamp_str)) = name_email.rsplit_once(' ') {
+                        if let Ok(ts) = timestamp_str.parse::<i64>() {
+                            commit_timestamp = ts;
+                        }
+                        if let Some((name, email)) = name_email.rsplit_once(" <") {
+                            author_name = SharedString::from(name.to_string());
+                            author_email =
+                                SharedString::from(email.trim_end_matches('>').to_string());
+                        }
+                    }
+                }
+            }
+        } else if subject.is_none() {
+            subject = Some(SharedString::from(line.to_string()));
+        }
+    }
+
+    Some(GraphCommitData {
+        sha,
+        parents,
+        author_name,
+        author_email,
+        commit_timestamp,
+        subject: subject.unwrap_or_default(),
+    })
+}
+
 #[derive(Clone, Debug, Hash, PartialEq, Eq)]
 pub struct Branch {
     pub is_head: bool,
@@ -420,6 +524,46 @@ impl Drop for GitExcludeOverride {
     }
 }
 
+#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Copy)]
+pub enum LogOrder {
+    #[default]
+    DateOrder,
+    TopoOrder,
+    AuthorDateOrder,
+    ReverseChronological,
+}
+
+impl LogOrder {
+    pub fn as_arg(&self) -> &'static str {
+        match self {
+            LogOrder::DateOrder => "--date-order",
+            LogOrder::TopoOrder => "--topo-order",
+            LogOrder::AuthorDateOrder => "--author-date-order",
+            LogOrder::ReverseChronological => "--reverse",
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
+pub enum LogSource {
+    #[default]
+    All,
+    Branch(SharedString),
+    Sha(Oid),
+}
+
+impl LogSource {
+    fn get_arg(&self) -> Result<&str> {
+        match self {
+            LogSource::All => Ok("--all"),
+            LogSource::Branch(branch) => Ok(branch.as_str()),
+            LogSource::Sha(oid) => {
+                str::from_utf8(oid.as_bytes()).context("Failed to build str from sha")
+            }
+        }
+    }
+}
+
 pub trait GitRepository: Send + Sync {
     fn reload_index(&self);
 
@@ -653,6 +797,17 @@ pub trait GitRepository: Send + Sync {
         &self,
         include_remote_name: bool,
     ) -> BoxFuture<'_, Result<Option<SharedString>>>;
+
+    /// Runs `git rev-list --parents` to get the commit graph structure.
+    /// Returns commit SHAs and their parent SHAs for building the graph visualization.
+    fn initial_graph_data(
+        &self,
+        log_source: LogSource,
+        log_order: LogOrder,
+        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
+    ) -> BoxFuture<'_, Result<()>>;
+
+    fn commit_data_reader(&self) -> Result<CommitDataReader>;
 }
 
 pub enum DiffType {
@@ -2412,6 +2567,215 @@ impl GitRepository for RealGitRepository {
         }
         .boxed()
     }
+
+    fn initial_graph_data(
+        &self,
+        log_source: LogSource,
+        log_order: LogOrder,
+        request_tx: Sender<Vec<Arc<InitialGraphCommitData>>>,
+    ) -> BoxFuture<'_, Result<()>> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = self.working_directory();
+        let executor = self.executor.clone();
+
+        async move {
+            let working_directory = working_directory?;
+            let git = GitBinary::new(git_binary_path, working_directory, executor);
+
+            let mut command = git.build_command([
+                "log",
+                GRAPH_COMMIT_FORMAT,
+                log_order.as_arg(),
+                log_source.get_arg()?,
+            ]);
+            command.stdout(Stdio::piped());
+            command.stderr(Stdio::null());
+
+            let mut child = command.spawn()?;
+            let stdout = child.stdout.take().context("failed to get stdout")?;
+            let mut reader = BufReader::new(stdout);
+
+            let mut line_buffer = String::new();
+            let mut lines: Vec<String> = Vec::with_capacity(GRAPH_CHUNK_SIZE);
+
+            loop {
+                line_buffer.clear();
+                let bytes_read = reader.read_line(&mut line_buffer).await?;
+
+                if bytes_read == 0 {
+                    if !lines.is_empty() {
+                        let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
+                        if request_tx.send(commits).await.is_err() {
+                            log::warn!(
+                                "initial_graph_data: receiver dropped while sending commits"
+                            );
+                        }
+                    }
+                    break;
+                }
+
+                let line = line_buffer.trim_end_matches('\n').to_string();
+                lines.push(line);
+
+                if lines.len() >= GRAPH_CHUNK_SIZE {
+                    let commits = parse_initial_graph_output(lines.iter().map(|s| s.as_str()));
+                    if request_tx.send(commits).await.is_err() {
+                        log::warn!("initial_graph_data: receiver dropped while streaming commits");
+                        break;
+                    }
+                    lines.clear();
+                }
+            }
+
+            child.status().await?;
+            Ok(())
+        }
+        .boxed()
+    }
+
+    fn commit_data_reader(&self) -> Result<CommitDataReader> {
+        let git_binary_path = self.any_git_binary_path.clone();
+        let working_directory = self
+            .working_directory()
+            .map_err(|_| anyhow!("no working directory"))?;
+        let executor = self.executor.clone();
+
+        let (request_tx, request_rx) = smol::channel::bounded::<CommitDataRequest>(64);
+
+        let task = self.executor.spawn(async move {
+            if let Err(error) =
+                run_commit_data_reader(git_binary_path, working_directory, executor, request_rx)
+                    .await
+            {
+                log::error!("commit data reader failed: {error:?}");
+            }
+        });
+
+        Ok(CommitDataReader {
+            request_tx,
+            _task: task,
+        })
+    }
+}
+
+async fn run_commit_data_reader(
+    git_binary_path: PathBuf,
+    working_directory: PathBuf,
+    executor: BackgroundExecutor,
+    request_rx: smol::channel::Receiver<CommitDataRequest>,
+) -> Result<()> {
+    let git = GitBinary::new(git_binary_path, working_directory, executor);
+    let mut process = git
+        .build_command(["--no-optional-locks", "cat-file", "--batch"])
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .spawn()
+        .context("starting git cat-file --batch process")?;
+
+    let mut stdin = BufWriter::new(process.stdin.take().context("no stdin")?);
+    let mut stdout = BufReader::new(process.stdout.take().context("no stdout")?);
+
+    const MAX_BATCH_SIZE: usize = 64;
+
+    while let Ok(first_request) = request_rx.recv().await {
+        let mut pending_requests = vec![first_request];
+
+        while pending_requests.len() < MAX_BATCH_SIZE {
+            match request_rx.try_recv() {
+                Ok(request) => pending_requests.push(request),
+                Err(_) => break,
+            }
+        }
+
+        for request in &pending_requests {
+            stdin.write_all(request.sha.to_string().as_bytes()).await?;
+            stdin.write_all(b"\n").await?;
+        }
+        stdin.flush().await?;
+
+        for request in pending_requests {
+            let result = read_single_commit_response(&mut stdout, &request.sha).await;
+            request.response_tx.send(result).ok();
+        }
+    }
+
+    drop(stdin);
+    process.kill().ok();
+
+    Ok(())
+}
+
+async fn read_single_commit_response(
+    stdout: &mut BufReader<smol::process::ChildStdout>,
+    sha: &Oid,
+) -> Result<GraphCommitData> {
+    let mut header_bytes = Vec::new();
+    stdout.read_until(b'\n', &mut header_bytes).await?;
+    let header_line = String::from_utf8_lossy(&header_bytes);
+
+    let parts: Vec<&str> = header_line.trim().split(' ').collect();
+    if parts.len() < 3 {
+        bail!("invalid cat-file header: {header_line}");
+    }
+
+    let object_type = parts[1];
+    if object_type == "missing" {
+        bail!("object not found: {}", sha);
+    }
+
+    if object_type != "commit" {
+        bail!("expected commit object, got {object_type}");
+    }
+
+    let size: usize = parts[2]
+        .parse()
+        .with_context(|| format!("invalid object size: {}", parts[2]))?;
+
+    let mut content = vec![0u8; size];
+    stdout.read_exact(&mut content).await?;
+
+    let mut newline = [0u8; 1];
+    stdout.read_exact(&mut newline).await?;
+
+    let content_str = String::from_utf8_lossy(&content);
+    parse_cat_file_commit(*sha, &content_str)
+        .ok_or_else(|| anyhow!("failed to parse commit {}", sha))
+}
+
+fn parse_initial_graph_output<'a>(
+    lines: impl Iterator<Item = &'a str>,
+) -> Vec<Arc<InitialGraphCommitData>> {
+    lines
+        .filter(|line| !line.is_empty())
+        .filter_map(|line| {
+            // Format: "SHA\x00PARENT1 PARENT2...\x00REF1, REF2, ..."
+            let mut parts = line.split('\x00');
+
+            let sha = Oid::from_str(parts.next()?).ok()?;
+            let parents_str = parts.next()?;
+            let parents = parents_str
+                .split_whitespace()
+                .filter_map(|p| Oid::from_str(p).ok())
+                .collect();
+
+            let ref_names_str = parts.next().unwrap_or("");
+            let ref_names = if ref_names_str.is_empty() {
+                Vec::new()
+            } else {
+                ref_names_str
+                    .split(", ")
+                    .map(|s| SharedString::from(s.to_string()))
+                    .collect()
+            };
+
+            Some(Arc::new(InitialGraphCommitData {
+                sha,
+                parents,
+                ref_names,
+            }))
+        })
+        .collect()
 }
 
 fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {

crates/git_graph/Cargo.toml 🔗

@@ -0,0 +1,46 @@
+[package]
+name = "git_graph"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/git_graph.rs"
+
+[features]
+default = []
+test-support = [
+    "project/test-support",
+    "gpui/test-support",
+]
+
+[dependencies]
+anyhow.workspace = true
+collections.workspace = true
+db.workspace = true
+git.workspace = true
+git_ui.workspace = true
+gpui.workspace = true
+project.workspace = true
+settings.workspace = true
+smallvec.workspace = true
+theme.workspace = true
+time.workspace = true
+ui.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+db = { workspace = true, features = ["test-support"] }
+fs = { workspace = true, features = ["test-support"] }
+git = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+rand.workspace = true
+recent_projects = { workspace = true, features = ["test-support"] }
+serde_json.workspace = true
+settings = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }

crates/git_graph/src/git_graph.rs 🔗

@@ -0,0 +1,2359 @@
+use collections::{BTreeMap, HashMap};
+use git::{
+    BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote,
+    parse_git_remote_url,
+    repository::{CommitDiff, InitialGraphCommitData, LogOrder, LogSource},
+};
+use git_ui::commit_tooltip::CommitAvatar;
+use gpui::{
+    AnyElement, App, Bounds, ClipboardItem, Context, Corner, DefiniteLength, ElementId, Entity,
+    EventEmitter, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, ParentElement,
+    PathBuilder, Pixels, Point, Render, ScrollWheelEvent, SharedString, Styled, Subscription, Task,
+    WeakEntity, Window, actions, anchored, deferred, point, px,
+};
+use project::{
+    Project,
+    git_store::{CommitDataState, GitStoreEvent, Repository, RepositoryEvent},
+};
+use settings::Settings;
+use smallvec::{SmallVec, smallvec};
+use std::{ops::Range, rc::Rc, sync::Arc, sync::OnceLock};
+use theme::{AccentColors, ThemeSettings};
+use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
+use ui::{ContextMenu, ScrollableHandle, Table, TableInteractionState, Tooltip, prelude::*};
+use workspace::{
+    Workspace,
+    item::{Item, ItemEvent, SerializableItem},
+};
+
+const COMMIT_CIRCLE_RADIUS: Pixels = px(4.5);
+const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5);
+const LANE_WIDTH: Pixels = px(16.0);
+const LEFT_PADDING: Pixels = px(12.0);
+const LINE_WIDTH: Pixels = px(1.5);
+
+actions!(
+    git_graph,
+    [
+        /// Opens the Git Graph panel.
+        Open,
+        /// Opens the commit view for the selected commit.
+        OpenCommitView,
+    ]
+);
+
+fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] {
+    static FORMAT: OnceLock<Vec<BorrowedFormatItem<'static>>> = OnceLock::new();
+    FORMAT.get_or_init(|| {
+        time::format_description::parse("[day] [month repr:short] [year] [hour]:[minute]")
+            .unwrap_or_default()
+    })
+}
+
+fn format_timestamp(timestamp: i64) -> String {
+    let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) else {
+        return "Unknown".to_string();
+    };
+
+    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
+    let local_datetime = datetime.to_offset(local_offset);
+
+    local_datetime
+        .format(timestamp_format())
+        .unwrap_or_default()
+}
+
+fn accent_colors_count(accents: &AccentColors) -> usize {
+    accents.0.len()
+}
+
+#[derive(Copy, Clone, Debug)]
+struct BranchColor(u8);
+
+#[derive(Debug)]
+enum LaneState {
+    Empty,
+    Active {
+        child: Oid,
+        parent: Oid,
+        color: Option<BranchColor>,
+        starting_row: usize,
+        starting_col: usize,
+        destination_column: Option<usize>,
+        segments: SmallVec<[CommitLineSegment; 1]>,
+    },
+}
+
+impl LaneState {
+    fn to_commit_lines(
+        &mut self,
+        ending_row: usize,
+        lane_column: usize,
+        parent_column: usize,
+        parent_color: BranchColor,
+    ) -> Option<CommitLine> {
+        let state = std::mem::replace(self, LaneState::Empty);
+
+        match state {
+            LaneState::Active {
+                #[cfg_attr(not(test), allow(unused_variables))]
+                parent,
+                #[cfg_attr(not(test), allow(unused_variables))]
+                child,
+                color,
+                starting_row,
+                starting_col,
+                destination_column,
+                mut segments,
+            } => {
+                let final_destination = destination_column.unwrap_or(parent_column);
+                let final_color = color.unwrap_or(parent_color);
+
+                Some(CommitLine {
+                    #[cfg(test)]
+                    child,
+                    #[cfg(test)]
+                    parent,
+                    child_column: starting_col,
+                    full_interval: starting_row..ending_row,
+                    color_idx: final_color.0 as usize,
+                    segments: {
+                        match segments.last_mut() {
+                            Some(CommitLineSegment::Straight { to_row })
+                                if *to_row == usize::MAX =>
+                            {
+                                if final_destination != lane_column {
+                                    *to_row = ending_row - 1;
+
+                                    let curved_line = CommitLineSegment::Curve {
+                                        to_column: final_destination,
+                                        on_row: ending_row,
+                                        curve_kind: CurveKind::Checkout,
+                                    };
+
+                                    if *to_row == starting_row {
+                                        let last_index = segments.len() - 1;
+                                        segments[last_index] = curved_line;
+                                    } else {
+                                        segments.push(curved_line);
+                                    }
+                                } else {
+                                    *to_row = ending_row;
+                                }
+                            }
+                            Some(CommitLineSegment::Curve {
+                                on_row,
+                                to_column,
+                                curve_kind,
+                            }) if *on_row == usize::MAX => {
+                                if *to_column == usize::MAX {
+                                    *to_column = final_destination;
+                                }
+                                if matches!(curve_kind, CurveKind::Merge) {
+                                    *on_row = starting_row + 1;
+                                    if *on_row < ending_row {
+                                        if *to_column != final_destination {
+                                            segments.push(CommitLineSegment::Straight {
+                                                to_row: ending_row - 1,
+                                            });
+                                            segments.push(CommitLineSegment::Curve {
+                                                to_column: final_destination,
+                                                on_row: ending_row,
+                                                curve_kind: CurveKind::Checkout,
+                                            });
+                                        } else {
+                                            segments.push(CommitLineSegment::Straight {
+                                                to_row: ending_row,
+                                            });
+                                        }
+                                    } else if *to_column != final_destination {
+                                        segments.push(CommitLineSegment::Curve {
+                                            to_column: final_destination,
+                                            on_row: ending_row,
+                                            curve_kind: CurveKind::Checkout,
+                                        });
+                                    }
+                                } else {
+                                    *on_row = ending_row;
+                                    if *to_column != final_destination {
+                                        segments.push(CommitLineSegment::Straight {
+                                            to_row: ending_row,
+                                        });
+                                        segments.push(CommitLineSegment::Curve {
+                                            to_column: final_destination,
+                                            on_row: ending_row,
+                                            curve_kind: CurveKind::Checkout,
+                                        });
+                                    }
+                                }
+                            }
+                            Some(CommitLineSegment::Curve {
+                                on_row, to_column, ..
+                            }) => {
+                                if *on_row < ending_row {
+                                    if *to_column != final_destination {
+                                        segments.push(CommitLineSegment::Straight {
+                                            to_row: ending_row - 1,
+                                        });
+                                        segments.push(CommitLineSegment::Curve {
+                                            to_column: final_destination,
+                                            on_row: ending_row,
+                                            curve_kind: CurveKind::Checkout,
+                                        });
+                                    } else {
+                                        segments.push(CommitLineSegment::Straight {
+                                            to_row: ending_row,
+                                        });
+                                    }
+                                } else if *to_column != final_destination {
+                                    segments.push(CommitLineSegment::Curve {
+                                        to_column: final_destination,
+                                        on_row: ending_row,
+                                        curve_kind: CurveKind::Checkout,
+                                    });
+                                }
+                            }
+                            _ => {}
+                        }
+
+                        segments
+                    },
+                })
+            }
+            LaneState::Empty => None,
+        }
+    }
+
+    fn is_empty(&self) -> bool {
+        match self {
+            LaneState::Empty => true,
+            LaneState::Active { .. } => false,
+        }
+    }
+}
+
+struct CommitEntry {
+    data: Arc<InitialGraphCommitData>,
+    lane: usize,
+    color_idx: usize,
+}
+
+type ActiveLaneIdx = usize;
+
+enum AllCommitCount {
+    NotLoaded,
+    Loaded(usize),
+}
+
+#[derive(Debug)]
+enum CurveKind {
+    Merge,
+    Checkout,
+}
+
+#[derive(Debug)]
+enum CommitLineSegment {
+    Straight {
+        to_row: usize,
+    },
+    Curve {
+        to_column: usize,
+        on_row: usize,
+        curve_kind: CurveKind,
+    },
+}
+
+#[derive(Debug)]
+struct CommitLine {
+    #[cfg(test)]
+    child: Oid,
+    #[cfg(test)]
+    parent: Oid,
+    child_column: usize,
+    full_interval: Range<usize>,
+    color_idx: usize,
+    segments: SmallVec<[CommitLineSegment; 1]>,
+}
+
+impl CommitLine {
+    fn get_first_visible_segment_idx(&self, first_visible_row: usize) -> Option<(usize, usize)> {
+        if first_visible_row > self.full_interval.end {
+            return None;
+        } else if first_visible_row <= self.full_interval.start {
+            return Some((0, self.child_column));
+        }
+
+        let mut current_column = self.child_column;
+
+        for (idx, segment) in self.segments.iter().enumerate() {
+            match segment {
+                CommitLineSegment::Straight { to_row } => {
+                    if *to_row >= first_visible_row {
+                        return Some((idx, current_column));
+                    }
+                }
+                CommitLineSegment::Curve {
+                    to_column, on_row, ..
+                } => {
+                    if *on_row >= first_visible_row {
+                        return Some((idx, current_column));
+                    }
+                    current_column = *to_column;
+                }
+            }
+        }
+
+        None
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct CommitLineKey {
+    child: Oid,
+    parent: Oid,
+}
+
+struct GraphData {
+    lane_states: SmallVec<[LaneState; 8]>,
+    lane_colors: HashMap<ActiveLaneIdx, BranchColor>,
+    parent_to_lanes: HashMap<Oid, SmallVec<[usize; 1]>>,
+    next_color: BranchColor,
+    accent_colors_count: usize,
+    commits: Vec<Rc<CommitEntry>>,
+    max_commit_count: AllCommitCount,
+    max_lanes: usize,
+    lines: Vec<Rc<CommitLine>>,
+    active_commit_lines: HashMap<CommitLineKey, usize>,
+    active_commit_lines_by_parent: HashMap<Oid, SmallVec<[usize; 1]>>,
+}
+
+impl GraphData {
+    fn new(accent_colors_count: usize) -> Self {
+        GraphData {
+            lane_states: SmallVec::default(),
+            lane_colors: HashMap::default(),
+            parent_to_lanes: HashMap::default(),
+            next_color: BranchColor(0),
+            accent_colors_count,
+            commits: Vec::default(),
+            max_commit_count: AllCommitCount::NotLoaded,
+            max_lanes: 0,
+            lines: Vec::default(),
+            active_commit_lines: HashMap::default(),
+            active_commit_lines_by_parent: HashMap::default(),
+        }
+    }
+
+    fn clear(&mut self) {
+        self.lane_states.clear();
+        self.lane_colors.clear();
+        self.parent_to_lanes.clear();
+        self.commits.clear();
+        self.lines.clear();
+        self.active_commit_lines.clear();
+        self.active_commit_lines_by_parent.clear();
+        self.next_color = BranchColor(0);
+        self.max_commit_count = AllCommitCount::NotLoaded;
+        self.max_lanes = 0;
+    }
+
+    fn first_empty_lane_idx(&mut self) -> ActiveLaneIdx {
+        self.lane_states
+            .iter()
+            .position(LaneState::is_empty)
+            .unwrap_or_else(|| {
+                self.lane_states.push(LaneState::Empty);
+                self.lane_states.len() - 1
+            })
+    }
+
+    fn get_lane_color(&mut self, lane_idx: ActiveLaneIdx) -> BranchColor {
+        let accent_colors_count = self.accent_colors_count;
+        *self.lane_colors.entry(lane_idx).or_insert_with(|| {
+            let color_idx = self.next_color;
+            self.next_color = BranchColor((self.next_color.0 + 1) % accent_colors_count as u8);
+            color_idx
+        })
+    }
+
+    fn add_commits(&mut self, commits: &[Arc<InitialGraphCommitData>]) {
+        self.commits.reserve(commits.len());
+        self.lines.reserve(commits.len() / 2);
+
+        for commit in commits.iter() {
+            let commit_row = self.commits.len();
+
+            let commit_lane = self
+                .parent_to_lanes
+                .get(&commit.sha)
+                .and_then(|lanes| lanes.first().copied());
+
+            let commit_lane = commit_lane.unwrap_or_else(|| self.first_empty_lane_idx());
+
+            let commit_color = self.get_lane_color(commit_lane);
+
+            if let Some(lanes) = self.parent_to_lanes.remove(&commit.sha) {
+                for lane_column in lanes {
+                    let state = &mut self.lane_states[lane_column];
+
+                    if let LaneState::Active {
+                        starting_row,
+                        segments,
+                        ..
+                    } = state
+                    {
+                        if let Some(CommitLineSegment::Curve {
+                            to_column,
+                            curve_kind: CurveKind::Merge,
+                            ..
+                        }) = segments.first_mut()
+                        {
+                            let curve_row = *starting_row + 1;
+                            let would_overlap =
+                                if lane_column != commit_lane && curve_row < commit_row {
+                                    self.commits[curve_row..commit_row]
+                                        .iter()
+                                        .any(|c| c.lane == commit_lane)
+                                } else {
+                                    false
+                                };
+
+                            if would_overlap {
+                                *to_column = lane_column;
+                            }
+                        }
+                    }
+
+                    if let Some(commit_line) =
+                        state.to_commit_lines(commit_row, lane_column, commit_lane, commit_color)
+                    {
+                        self.lines.push(Rc::new(commit_line));
+                    }
+                }
+            }
+
+            commit
+                .parents
+                .iter()
+                .enumerate()
+                .for_each(|(parent_idx, parent)| {
+                    if parent_idx == 0 {
+                        self.lane_states[commit_lane] = LaneState::Active {
+                            parent: *parent,
+                            child: commit.sha,
+                            color: Some(commit_color),
+                            starting_col: commit_lane,
+                            starting_row: commit_row,
+                            destination_column: None,
+                            segments: smallvec![CommitLineSegment::Straight { to_row: usize::MAX }],
+                        };
+
+                        self.parent_to_lanes
+                            .entry(*parent)
+                            .or_default()
+                            .push(commit_lane);
+                    } else {
+                        let new_lane = self.first_empty_lane_idx();
+
+                        self.lane_states[new_lane] = LaneState::Active {
+                            parent: *parent,
+                            child: commit.sha,
+                            color: None,
+                            starting_col: commit_lane,
+                            starting_row: commit_row,
+                            destination_column: None,
+                            segments: smallvec![CommitLineSegment::Curve {
+                                to_column: usize::MAX,
+                                on_row: usize::MAX,
+                                curve_kind: CurveKind::Merge,
+                            },],
+                        };
+
+                        self.parent_to_lanes
+                            .entry(*parent)
+                            .or_default()
+                            .push(new_lane);
+                    }
+                });
+
+            self.max_lanes = self.max_lanes.max(self.lane_states.len());
+
+            self.commits.push(Rc::new(CommitEntry {
+                data: commit.clone(),
+                lane: commit_lane,
+                color_idx: commit_color.0 as usize,
+            }));
+        }
+    }
+}
+
+pub fn init(cx: &mut App) {
+    workspace::register_serializable_item::<GitGraph>(cx);
+
+    cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
+        workspace.register_action_renderer(|div, workspace, _, cx| {
+            div.when(
+                workspace.project().read(cx).active_repository(cx).is_some(),
+                |div| {
+                    let workspace = workspace.weak_handle();
+
+                    div.on_action(move |_: &Open, window, cx| {
+                        workspace
+                            .update(cx, |workspace, cx| {
+                                let project = workspace.project().clone();
+                                let git_graph = cx.new(|cx| GitGraph::new(project, window, cx));
+                                workspace.add_item_to_active_pane(
+                                    Box::new(git_graph),
+                                    None,
+                                    true,
+                                    window,
+                                    cx,
+                                );
+                            })
+                            .ok();
+                    })
+                },
+            )
+        });
+    })
+    .detach();
+}
+
+fn lane_center_x(bounds: Bounds<Pixels>, lane: f32, horizontal_scroll_offset: Pixels) -> Pixels {
+    bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 - horizontal_scroll_offset
+}
+
+fn to_row_center(
+    to_row: usize,
+    row_height: Pixels,
+    scroll_offset: Pixels,
+    bounds: Bounds<Pixels>,
+) -> Pixels {
+    bounds.origin.y + to_row as f32 * row_height + row_height / 2.0 - scroll_offset
+}
+
+fn draw_commit_circle(center_x: Pixels, center_y: Pixels, color: Hsla, window: &mut Window) {
+    let radius = COMMIT_CIRCLE_RADIUS;
+    let stroke_width = COMMIT_CIRCLE_STROKE_WIDTH;
+
+    let mut builder = PathBuilder::stroke(stroke_width);
+
+    // Start at the rightmost point of the circle
+    builder.move_to(point(center_x + radius, center_y));
+
+    // Draw the circle using two arc_to calls (top half, then bottom half)
+    builder.arc_to(
+        point(radius, radius),
+        px(0.),
+        false,
+        true,
+        point(center_x - radius, center_y),
+    );
+    builder.arc_to(
+        point(radius, radius),
+        px(0.),
+        false,
+        true,
+        point(center_x + radius, center_y),
+    );
+    builder.close();
+
+    if let Ok(path) = builder.build() {
+        window.paint_path(path, color);
+    }
+}
+
+pub struct GitGraph {
+    focus_handle: FocusHandle,
+    graph_data: GraphData,
+    project: Entity<Project>,
+    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
+    row_height: Pixels,
+    table_interaction_state: Entity<TableInteractionState>,
+    horizontal_scroll_offset: Pixels,
+    graph_viewport_width: Pixels,
+    selected_entry_idx: Option<usize>,
+    log_source: LogSource,
+    log_order: LogOrder,
+    selected_commit_diff: Option<CommitDiff>,
+    _commit_diff_task: Option<Task<()>>,
+    _load_task: Option<Task<()>>,
+}
+
+impl GitGraph {
+    fn row_height(cx: &App) -> Pixels {
+        let settings = ThemeSettings::get_global(cx);
+        let font_size = settings.buffer_font_size(cx);
+        font_size + px(12.0)
+    }
+
+    fn graph_content_width(&self) -> Pixels {
+        (LANE_WIDTH * self.graph_data.max_lanes.min(8) as f32) + LEFT_PADDING * 2.0
+    }
+
+    pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let focus_handle = cx.focus_handle();
+        cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
+            .detach();
+
+        let git_store = project.read(cx).git_store().clone();
+        let accent_colors = cx.theme().accents();
+        let mut graph = GraphData::new(accent_colors_count(accent_colors));
+        let log_source = LogSource::default();
+        let log_order = LogOrder::default();
+
+        cx.subscribe(&git_store, |this, _, event, cx| match event {
+            GitStoreEvent::RepositoryUpdated(_, repo_event, is_active) => {
+                if *is_active {
+                    if let Some(repository) = this.project.read(cx).active_repository(cx) {
+                        this.on_repository_event(repository, repo_event, cx);
+                    }
+                }
+            }
+            GitStoreEvent::ActiveRepositoryChanged(_) => {
+                this.graph_data.clear();
+                cx.notify();
+            }
+            _ => {}
+        })
+        .detach();
+
+        if let Some(repository) = project.read(cx).active_repository(cx) {
+            repository.update(cx, |repository, cx| {
+                let commits =
+                    repository.graph_data(log_source.clone(), log_order, 0..usize::MAX, cx);
+                graph.add_commits(commits);
+            });
+        }
+
+        let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
+        let mut row_height = Self::row_height(cx);
+
+        cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
+            let new_row_height = Self::row_height(cx);
+            if new_row_height != row_height {
+                this.row_height = new_row_height;
+                this.table_interaction_state.update(cx, |state, _cx| {
+                    state.scroll_handle.0.borrow_mut().last_item_size = None;
+                });
+                row_height = new_row_height;
+            }
+            cx.notify();
+        })
+        .detach();
+
+        GitGraph {
+            focus_handle,
+            project,
+            graph_data: graph,
+            _load_task: None,
+            _commit_diff_task: None,
+            context_menu: None,
+            row_height,
+            table_interaction_state,
+            horizontal_scroll_offset: px(0.),
+            graph_viewport_width: px(88.),
+            selected_entry_idx: None,
+            selected_commit_diff: None,
+            log_source,
+            log_order,
+        }
+    }
+
+    fn on_repository_event(
+        &mut self,
+        repository: Entity<Repository>,
+        event: &RepositoryEvent,
+        cx: &mut Context<Self>,
+    ) {
+        match event {
+            RepositoryEvent::GitGraphCountUpdated(_, commit_count) => {
+                let old_count = self.graph_data.commits.len();
+
+                repository.update(cx, |repository, cx| {
+                    let commits = repository.graph_data(
+                        self.log_source.clone(),
+                        self.log_order,
+                        old_count..*commit_count,
+                        cx,
+                    );
+                    self.graph_data.add_commits(commits);
+                });
+
+                self.graph_data.max_commit_count = AllCommitCount::Loaded(*commit_count);
+            }
+            RepositoryEvent::BranchChanged => {
+                self.graph_data.clear();
+                cx.notify();
+            }
+            _ => {}
+        }
+    }
+
+    fn render_badge(&self, name: &SharedString, accent_color: gpui::Hsla) -> impl IntoElement {
+        div()
+            .px_1p5()
+            .py_0p5()
+            .h(self.row_height - px(4.0))
+            .flex()
+            .items_center()
+            .justify_center()
+            .rounded_md()
+            .bg(accent_color.opacity(0.18))
+            .border_1()
+            .border_color(accent_color.opacity(0.55))
+            .child(
+                Label::new(name.clone())
+                    .size(LabelSize::Small)
+                    .color(Color::Default)
+                    .single_line(),
+            )
+    }
+
+    fn render_table_rows(
+        &mut self,
+        range: Range<usize>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<Vec<AnyElement>> {
+        let repository = self
+            .project
+            .read_with(cx, |project, cx| project.active_repository(cx));
+
+        let row_height = self.row_height;
+
+        // We fetch data outside the visible viewport to avoid loading entries when
+        // users scroll through the git graph
+        if let Some(repository) = repository.as_ref() {
+            const FETCH_RANGE: usize = 100;
+            repository.update(cx, |repository, cx| {
+                self.graph_data.commits[range.start.saturating_sub(FETCH_RANGE)
+                    ..(range.end + FETCH_RANGE)
+                        .min(self.graph_data.commits.len().saturating_sub(1))]
+                    .iter()
+                    .for_each(|commit| {
+                        repository.fetch_commit_data(commit.data.sha, cx);
+                    });
+            });
+        }
+
+        range
+            .map(|idx| {
+                let Some((commit, repository)) =
+                    self.graph_data.commits.get(idx).zip(repository.as_ref())
+                else {
+                    return vec![
+                        div().h(row_height).into_any_element(),
+                        div().h(row_height).into_any_element(),
+                        div().h(row_height).into_any_element(),
+                        div().h(row_height).into_any_element(),
+                    ];
+                };
+
+                let data = repository.update(cx, |repository, cx| {
+                    repository.fetch_commit_data(commit.data.sha, cx).clone()
+                });
+
+                let short_sha = commit.data.sha.display_short();
+                let mut formatted_time = String::new();
+                let subject;
+                let author_name;
+
+                if let CommitDataState::Loaded(data) = data {
+                    subject = data.subject.clone();
+                    author_name = data.author_name.clone();
+                    formatted_time = format_timestamp(data.commit_timestamp);
+                } else {
+                    subject = "Loading...".into();
+                    author_name = "".into();
+                }
+
+                let accent_colors = cx.theme().accents();
+                let accent_color = accent_colors
+                    .0
+                    .get(commit.color_idx)
+                    .copied()
+                    .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
+                let is_selected = self.selected_entry_idx == Some(idx);
+                let text_color = if is_selected {
+                    Color::Default
+                } else {
+                    Color::Muted
+                };
+
+                vec![
+                    div()
+                        .id(ElementId::NamedInteger("commit-subject".into(), idx as u64))
+                        .overflow_hidden()
+                        .tooltip(Tooltip::text(subject.clone()))
+                        .child(
+                            h_flex()
+                                .gap_1()
+                                .items_center()
+                                .overflow_hidden()
+                                .children((!commit.data.ref_names.is_empty()).then(|| {
+                                    h_flex().flex_shrink().gap_2().items_center().children(
+                                        commit
+                                            .data
+                                            .ref_names
+                                            .iter()
+                                            .map(|name| self.render_badge(name, accent_color)),
+                                    )
+                                }))
+                                .child(
+                                    Label::new(subject)
+                                        .color(text_color)
+                                        .truncate()
+                                        .single_line(),
+                                ),
+                        )
+                        .into_any_element(),
+                    Label::new(formatted_time)
+                        .color(text_color)
+                        .single_line()
+                        .into_any_element(),
+                    Label::new(author_name)
+                        .color(text_color)
+                        .single_line()
+                        .into_any_element(),
+                    Label::new(short_sha)
+                        .color(text_color)
+                        .single_line()
+                        .into_any_element(),
+                ]
+            })
+            .collect()
+    }
+
+    fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
+        if self.selected_entry_idx == Some(idx) {
+            return;
+        }
+
+        self.selected_entry_idx = Some(idx);
+        self.selected_commit_diff = None;
+
+        let Some(commit) = self.graph_data.commits.get(idx) else {
+            return;
+        };
+
+        let sha = commit.data.sha.to_string();
+        let repository = self
+            .project
+            .read_with(cx, |project, cx| project.active_repository(cx));
+
+        let Some(repository) = repository else {
+            return;
+        };
+
+        let diff_receiver = repository.update(cx, |repo, _| repo.load_commit_diff(sha));
+
+        self._commit_diff_task = Some(cx.spawn(async move |this, cx| {
+            if let Ok(Ok(diff)) = diff_receiver.await {
+                this.update(cx, |this, cx| {
+                    this.selected_commit_diff = Some(diff);
+                    cx.notify();
+                })
+                .ok();
+            }
+        }));
+
+        cx.notify();
+    }
+
+    fn get_remote(
+        &self,
+        repository: &Repository,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Option<GitRemote> {
+        let remote_url = repository.default_remote_url()?;
+        let provider_registry = GitHostingProviderRegistry::default_global(cx);
+        let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
+        Some(GitRemote {
+            host: provider,
+            owner: parsed.owner.into(),
+            repo: parsed.repo.into(),
+        })
+    }
+
+    fn render_commit_detail_panel(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        let Some(selected_idx) = self.selected_entry_idx else {
+            return div().into_any_element();
+        };
+
+        let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else {
+            return div().into_any_element();
+        };
+
+        let repository = self
+            .project
+            .read_with(cx, |project, cx| project.active_repository(cx));
+
+        let Some(repository) = repository else {
+            return div().into_any_element();
+        };
+
+        let data = repository.update(cx, |repository, cx| {
+            repository
+                .fetch_commit_data(commit_entry.data.sha, cx)
+                .clone()
+        });
+
+        let full_sha: SharedString = commit_entry.data.sha.to_string().into();
+        let truncated_sha: SharedString = {
+            let sha_str = full_sha.as_ref();
+            if sha_str.len() > 24 {
+                format!("{}...", &sha_str[..24]).into()
+            } else {
+                full_sha.clone()
+            }
+        };
+        let ref_names = commit_entry.data.ref_names.clone();
+        let accent_colors = cx.theme().accents();
+        let accent_color = accent_colors
+            .0
+            .get(commit_entry.color_idx)
+            .copied()
+            .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default());
+
+        let (author_name, author_email, commit_timestamp, subject) = match &data {
+            CommitDataState::Loaded(data) => (
+                data.author_name.clone(),
+                data.author_email.clone(),
+                Some(data.commit_timestamp),
+                data.subject.clone(),
+            ),
+            CommitDataState::Loading => ("Loading...".into(), "".into(), None, "Loading...".into()),
+        };
+
+        let date_string = commit_timestamp
+            .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok())
+            .map(|datetime| {
+                let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
+                let local_datetime = datetime.to_offset(local_offset);
+                let format =
+                    time::format_description::parse("[month repr:short] [day], [year]").ok();
+                format
+                    .and_then(|f| local_datetime.format(&f).ok())
+                    .unwrap_or_default()
+            })
+            .unwrap_or_default();
+
+        let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx));
+
+        let avatar = {
+            let avatar = CommitAvatar::new(&full_sha, remote.as_ref());
+            v_flex()
+                .w(px(64.))
+                .h(px(64.))
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .rounded_full()
+                .justify_center()
+                .items_center()
+                .child(
+                    avatar
+                        .avatar(window, cx)
+                        .map(|a| a.size(px(64.)).into_any_element())
+                        .unwrap_or_else(|| {
+                            Icon::new(IconName::Person)
+                                .color(Color::Muted)
+                                .size(IconSize::XLarge)
+                                .into_any_element()
+                        }),
+                )
+        };
+
+        let changed_files_count = self
+            .selected_commit_diff
+            .as_ref()
+            .map(|diff| diff.files.len())
+            .unwrap_or(0);
+
+        v_flex()
+            .w(px(300.))
+            .h_full()
+            .border_l_1()
+            .border_color(cx.theme().colors().border)
+            .bg(cx.theme().colors().surface_background)
+            .child(
+                v_flex()
+                    .p_3()
+                    .gap_3()
+                    .child(
+                        h_flex().justify_between().child(avatar).child(
+                            IconButton::new("close-detail", IconName::Close)
+                                .icon_size(IconSize::Small)
+                                .on_click(cx.listener(move |this, _, _, cx| {
+                                    this.selected_entry_idx = None;
+                                    this.selected_commit_diff = None;
+                                    this._commit_diff_task = None;
+                                    cx.notify();
+                                })),
+                        ),
+                    )
+                    .child(
+                        v_flex()
+                            .gap_0p5()
+                            .child(Label::new(author_name.clone()).weight(FontWeight::SEMIBOLD))
+                            .child(
+                                Label::new(date_string)
+                                    .color(Color::Muted)
+                                    .size(LabelSize::Small),
+                            ),
+                    )
+                    .children((!ref_names.is_empty()).then(|| {
+                        h_flex().gap_1().flex_wrap().children(
+                            ref_names
+                                .iter()
+                                .map(|name| self.render_badge(name, accent_color)),
+                        )
+                    }))
+                    .child(
+                        v_flex()
+                            .gap_1p5()
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(
+                                        Icon::new(IconName::Person)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                    .child(
+                                        Label::new(author_name)
+                                            .size(LabelSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                    .when(!author_email.is_empty(), |this| {
+                                        this.child(
+                                            Label::new(format!("<{}>", author_email))
+                                                .size(LabelSize::Small)
+                                                .color(Color::Ignored),
+                                        )
+                                    }),
+                            )
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(
+                                        Icon::new(IconName::Hash)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
+                                    .child({
+                                        let copy_sha = full_sha.clone();
+                                        Button::new("sha-button", truncated_sha)
+                                            .style(ButtonStyle::Transparent)
+                                            .label_size(LabelSize::Small)
+                                            .color(Color::Muted)
+                                            .tooltip(Tooltip::text(format!(
+                                                "Copy SHA: {}",
+                                                copy_sha
+                                            )))
+                                            .on_click(move |_, _, cx| {
+                                                cx.write_to_clipboard(ClipboardItem::new_string(
+                                                    copy_sha.to_string(),
+                                                ));
+                                            })
+                                    }),
+                            )
+                            .when_some(remote.clone(), |this, remote| {
+                                let provider_name = remote.host.name();
+                                let icon = match provider_name.as_str() {
+                                    "GitHub" => IconName::Github,
+                                    _ => IconName::Link,
+                                };
+                                let parsed_remote = ParsedGitRemote {
+                                    owner: remote.owner.as_ref().into(),
+                                    repo: remote.repo.as_ref().into(),
+                                };
+                                let params = BuildCommitPermalinkParams {
+                                    sha: full_sha.as_ref(),
+                                };
+                                let url = remote
+                                    .host
+                                    .build_commit_permalink(&parsed_remote, params)
+                                    .to_string();
+                                this.child(
+                                    h_flex()
+                                        .gap_1()
+                                        .child(
+                                            Icon::new(icon)
+                                                .size(IconSize::Small)
+                                                .color(Color::Muted),
+                                        )
+                                        .child(
+                                            Button::new(
+                                                "view-on-provider",
+                                                format!("View on {}", provider_name),
+                                            )
+                                            .style(ButtonStyle::Transparent)
+                                            .label_size(LabelSize::Small)
+                                            .color(Color::Muted)
+                                            .on_click(
+                                                move |_, _, cx| {
+                                                    cx.open_url(&url);
+                                                },
+                                            ),
+                                        ),
+                                )
+                            }),
+                    ),
+            )
+            .child(
+                div()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border)
+                    .p_3()
+                    .child(
+                        v_flex()
+                            .gap_2()
+                            .child(Label::new(subject).weight(FontWeight::MEDIUM)),
+                    ),
+            )
+            .child(
+                div()
+                    .flex_1()
+                    .overflow_hidden()
+                    .border_t_1()
+                    .border_color(cx.theme().colors().border)
+                    .p_3()
+                    .child(
+                        v_flex()
+                            .gap_2()
+                            .child(
+                                Label::new(format!("{} Changed Files", changed_files_count))
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .children(self.selected_commit_diff.as_ref().map(|diff| {
+                                v_flex().gap_1().children(diff.files.iter().map(|file| {
+                                    let file_name: String = file
+                                        .path
+                                        .file_name()
+                                        .map(|n| n.to_string())
+                                        .unwrap_or_default();
+                                    let dir_path: String = file
+                                        .path
+                                        .parent()
+                                        .map(|p| p.as_unix_str().to_string())
+                                        .unwrap_or_default();
+
+                                    h_flex()
+                                        .gap_1()
+                                        .overflow_hidden()
+                                        .child(
+                                            Icon::new(IconName::File)
+                                                .size(IconSize::Small)
+                                                .color(Color::Accent),
+                                        )
+                                        .child(
+                                            Label::new(file_name)
+                                                .size(LabelSize::Small)
+                                                .single_line(),
+                                        )
+                                        .when(!dir_path.is_empty(), |this| {
+                                            this.child(
+                                                Label::new(dir_path)
+                                                    .size(LabelSize::Small)
+                                                    .color(Color::Muted)
+                                                    .single_line(),
+                                            )
+                                        })
+                                }))
+                            })),
+                    ),
+            )
+            .into_any_element()
+    }
+
+    pub fn render_graph(&self, cx: &mut Context<GitGraph>) -> impl IntoElement {
+        let row_height = self.row_height;
+        let table_state = self.table_interaction_state.read(cx);
+        let viewport_height = table_state
+            .scroll_handle
+            .0
+            .borrow()
+            .last_item_size
+            .map(|size| size.item.height)
+            .unwrap_or(px(600.0));
+        let loaded_commit_count = self.graph_data.commits.len();
+
+        let content_height = row_height * loaded_commit_count;
+        let max_scroll = (content_height - viewport_height).max(px(0.));
+        let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll);
+
+        let first_visible_row = (scroll_offset_y / row_height).floor() as usize;
+        let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height);
+        let horizontal_scroll_offset = self.horizontal_scroll_offset;
+
+        let max_lanes = self.graph_data.max_lanes.max(6);
+        let graph_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
+        let last_visible_row =
+            first_visible_row + (viewport_height / row_height).ceil() as usize + 1;
+
+        let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1))
+            ..(last_visible_row).min(loaded_commit_count);
+        let rows = self.graph_data.commits[viewport_range.clone()].to_vec();
+        let commit_lines: Vec<_> = self
+            .graph_data
+            .lines
+            .iter()
+            .filter(|line| {
+                line.full_interval.start <= viewport_range.end
+                    && line.full_interval.end >= viewport_range.start
+            })
+            .cloned()
+            .collect();
+
+        let mut lines: BTreeMap<usize, Vec<_>> = BTreeMap::new();
+
+        gpui::canvas(
+            move |_bounds, _window, _cx| {},
+            move |bounds: Bounds<Pixels>, _: (), window: &mut Window, cx: &mut App| {
+                window.paint_layer(bounds, |window| {
+                    let accent_colors = cx.theme().accents();
+
+                    for (row_idx, row) in rows.into_iter().enumerate() {
+                        let row_color = accent_colors.color_for_index(row.color_idx as u32);
+                        let row_y_center =
+                            bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0
+                                - vertical_scroll_offset;
+
+                        let commit_x =
+                            lane_center_x(bounds, row.lane as f32, horizontal_scroll_offset);
+
+                        draw_commit_circle(commit_x, row_y_center, row_color, window);
+                    }
+
+                    for line in commit_lines {
+                        let Some((start_segment_idx, start_column)) =
+                            line.get_first_visible_segment_idx(first_visible_row)
+                        else {
+                            continue;
+                        };
+
+                        let line_x =
+                            lane_center_x(bounds, start_column as f32, horizontal_scroll_offset);
+
+                        let start_row = line.full_interval.start as i32 - first_visible_row as i32;
+
+                        let from_y =
+                            bounds.origin.y + start_row as f32 * row_height + row_height / 2.0
+                                - vertical_scroll_offset
+                                + COMMIT_CIRCLE_RADIUS;
+
+                        let mut current_row = from_y;
+                        let mut current_column = line_x;
+
+                        let mut builder = PathBuilder::stroke(LINE_WIDTH);
+                        builder.move_to(point(line_x, from_y));
+
+                        let segments = &line.segments[start_segment_idx..];
+
+                        for (segment_idx, segment) in segments.iter().enumerate() {
+                            let is_last = segment_idx + 1 == segments.len();
+
+                            match segment {
+                                CommitLineSegment::Straight { to_row } => {
+                                    let mut dest_row = to_row_center(
+                                        to_row - first_visible_row,
+                                        row_height,
+                                        vertical_scroll_offset,
+                                        bounds,
+                                    );
+                                    if is_last {
+                                        dest_row -= COMMIT_CIRCLE_RADIUS;
+                                    }
+
+                                    let dest_point = point(current_column, dest_row);
+
+                                    current_row = dest_point.y;
+                                    builder.line_to(dest_point);
+                                    builder.move_to(dest_point);
+                                }
+                                CommitLineSegment::Curve {
+                                    to_column,
+                                    on_row,
+                                    curve_kind,
+                                } => {
+                                    let mut to_column = lane_center_x(
+                                        bounds,
+                                        *to_column as f32,
+                                        horizontal_scroll_offset,
+                                    );
+
+                                    let mut to_row = to_row_center(
+                                        *on_row - first_visible_row,
+                                        row_height,
+                                        vertical_scroll_offset,
+                                        bounds,
+                                    );
+
+                                    // This means that this branch was a checkout
+                                    let going_right = to_column > current_column;
+                                    let column_shift = if going_right {
+                                        COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH
+                                    } else {
+                                        -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH
+                                    };
+
+                                    let control = match curve_kind {
+                                        CurveKind::Checkout => {
+                                            if is_last {
+                                                to_column -= column_shift;
+                                            }
+                                            builder.move_to(point(current_column, current_row));
+                                            point(current_column, to_row)
+                                        }
+                                        CurveKind::Merge => {
+                                            if is_last {
+                                                to_row -= COMMIT_CIRCLE_RADIUS;
+                                            }
+                                            builder.move_to(point(
+                                                current_column + column_shift,
+                                                current_row - COMMIT_CIRCLE_RADIUS,
+                                            ));
+                                            point(to_column, current_row)
+                                        }
+                                    };
+
+                                    match curve_kind {
+                                        CurveKind::Checkout
+                                            if (to_row - current_row).abs() > row_height =>
+                                        {
+                                            let start_curve =
+                                                point(current_column, current_row + row_height);
+                                            builder.line_to(start_curve);
+                                            builder.move_to(start_curve);
+                                        }
+                                        CurveKind::Merge
+                                            if (to_column - current_column).abs() > LANE_WIDTH =>
+                                        {
+                                            let column_shift =
+                                                if going_right { LANE_WIDTH } else { -LANE_WIDTH };
+
+                                            let start_curve = point(
+                                                current_column + column_shift,
+                                                current_row - COMMIT_CIRCLE_RADIUS,
+                                            );
+
+                                            builder.line_to(start_curve);
+                                            builder.move_to(start_curve);
+                                        }
+                                        _ => {}
+                                    };
+
+                                    builder.curve_to(point(to_column, to_row), control);
+                                    current_row = to_row;
+                                    current_column = to_column;
+                                    builder.move_to(point(current_column, current_row));
+                                }
+                            }
+                        }
+
+                        builder.close();
+                        lines.entry(line.color_idx).or_default().push(builder);
+                    }
+
+                    for (color_idx, builders) in lines {
+                        let line_color = accent_colors.color_for_index(color_idx as u32);
+
+                        for builder in builders {
+                            if let Ok(path) = builder.build() {
+                                // we paint each color on it's own layer to stop overlapping lines
+                                // of different colors changing the color of a line
+                                window.paint_layer(bounds, |window| {
+                                    window.paint_path(path, line_color);
+                                });
+                            }
+                        }
+                    }
+                })
+            },
+        )
+        .w(graph_width)
+        .h_full()
+    }
+
+    fn handle_graph_scroll(
+        &mut self,
+        event: &ScrollWheelEvent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let line_height = window.line_height();
+        let delta = event.delta.pixel_delta(line_height);
+
+        let table_state = self.table_interaction_state.read(cx);
+        let current_offset = table_state.scroll_offset();
+
+        let viewport_height = table_state.scroll_handle.viewport().size.height;
+
+        let commit_count = match self.graph_data.max_commit_count {
+            AllCommitCount::Loaded(count) => count,
+            AllCommitCount::NotLoaded => self.graph_data.commits.len(),
+        };
+        let content_height = self.row_height * commit_count;
+        let max_vertical_scroll = (viewport_height - content_height).min(px(0.));
+
+        let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.));
+        let new_offset = Point::new(current_offset.x, new_y);
+
+        let max_lanes = self.graph_data.max_lanes.max(1);
+        let graph_content_width = LANE_WIDTH * max_lanes as f32 + LEFT_PADDING * 2.0;
+        let max_horizontal_scroll = (graph_content_width - self.graph_viewport_width).max(px(0.));
+
+        let new_horizontal_offset =
+            (self.horizontal_scroll_offset - delta.x).clamp(px(0.), max_horizontal_scroll);
+
+        let vertical_changed = new_offset != current_offset;
+        let horizontal_changed = new_horizontal_offset != self.horizontal_scroll_offset;
+
+        if vertical_changed {
+            table_state.set_scroll_offset(new_offset);
+        }
+
+        if horizontal_changed {
+            self.horizontal_scroll_offset = new_horizontal_offset;
+        }
+
+        if vertical_changed || horizontal_changed {
+            cx.notify();
+        }
+    }
+}
+
+impl Render for GitGraph {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let description_width_fraction = 0.72;
+        let date_width_fraction = 0.12;
+        let author_width_fraction = 0.10;
+        let commit_width_fraction = 0.06;
+
+        let commit_count = match self.graph_data.max_commit_count {
+            AllCommitCount::Loaded(count) => count,
+            AllCommitCount::NotLoaded => {
+                self.project.update(cx, |project, cx| {
+                    if let Some(repository) = project.active_repository(cx) {
+                        repository.update(cx, |repository, cx| {
+                            // Start loading the graph data if we haven't started already
+                            repository.graph_data(
+                                self.log_source.clone(),
+                                self.log_order,
+                                0..0,
+                                cx,
+                            );
+                        })
+                    }
+                });
+
+                self.graph_data.commits.len()
+            }
+        };
+
+        let content = if self.graph_data.commits.is_empty() {
+            let message = "No commits found";
+            div()
+                .size_full()
+                .flex()
+                .items_center()
+                .justify_center()
+                .child(Label::new(message).color(Color::Muted))
+        } else {
+            div()
+                .size_full()
+                .flex()
+                .flex_row()
+                .child(
+                    div()
+                        .w(self.graph_content_width())
+                        .h_full()
+                        .flex()
+                        .flex_col()
+                        .child(
+                            div()
+                                .p_2()
+                                .border_b_1()
+                                .border_color(cx.theme().colors().border)
+                                .child(Label::new("Graph").color(Color::Muted)),
+                        )
+                        .child(
+                            div()
+                                .id("graph-canvas")
+                                .flex_1()
+                                .overflow_hidden()
+                                .child(self.render_graph(cx))
+                                .on_scroll_wheel(cx.listener(Self::handle_graph_scroll)),
+                        ),
+                )
+                .child({
+                    let row_height = self.row_height;
+                    let selected_entry_idx = self.selected_entry_idx;
+                    let weak_self = cx.weak_entity();
+                    div().flex_1().size_full().child(
+                        Table::new(4)
+                            .interactable(&self.table_interaction_state)
+                            .hide_row_borders()
+                            .header(vec![
+                                Label::new("Description")
+                                    .color(Color::Muted)
+                                    .into_any_element(),
+                                Label::new("Date").color(Color::Muted).into_any_element(),
+                                Label::new("Author").color(Color::Muted).into_any_element(),
+                                Label::new("Commit").color(Color::Muted).into_any_element(),
+                            ])
+                            .column_widths(
+                                [
+                                    DefiniteLength::Fraction(description_width_fraction),
+                                    DefiniteLength::Fraction(date_width_fraction),
+                                    DefiniteLength::Fraction(author_width_fraction),
+                                    DefiniteLength::Fraction(commit_width_fraction),
+                                ]
+                                .to_vec(),
+                            )
+                            .map_row(move |(index, row), _window, cx| {
+                                let is_selected = selected_entry_idx == Some(index);
+                                let weak = weak_self.clone();
+                                row.h(row_height)
+                                    .when(is_selected, |row| {
+                                        row.bg(cx.theme().colors().element_selected)
+                                    })
+                                    .on_click(move |_, _, cx| {
+                                        weak.update(cx, |this, cx| {
+                                            this.select_entry(index, cx);
+                                        })
+                                        .ok();
+                                    })
+                                    .into_any_element()
+                            })
+                            .uniform_list(
+                                "git-graph-commits",
+                                commit_count,
+                                cx.processor(Self::render_table_rows),
+                            ),
+                    )
+                })
+                .when(self.selected_entry_idx.is_some(), |this| {
+                    this.child(self.render_commit_detail_panel(window, cx))
+                })
+        };
+
+        div()
+            .size_full()
+            .bg(cx.theme().colors().editor_background)
+            .key_context("GitGraph")
+            .track_focus(&self.focus_handle)
+            .child(content)
+            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
+                deferred(
+                    anchored()
+                        .position(*position)
+                        .anchor(Corner::TopLeft)
+                        .child(menu.clone()),
+                )
+                .with_priority(1)
+            }))
+    }
+}
+
+impl EventEmitter<ItemEvent> for GitGraph {}
+
+impl Focusable for GitGraph {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Item for GitGraph {
+    type Event = ItemEvent;
+
+    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+        "Git Graph".into()
+    }
+
+    fn show_toolbar(&self) -> bool {
+        false
+    }
+
+    fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
+        f(*event)
+    }
+}
+
+impl SerializableItem for GitGraph {
+    fn serialized_item_kind() -> &'static str {
+        "GitGraph"
+    }
+
+    fn cleanup(
+        workspace_id: workspace::WorkspaceId,
+        alive_items: Vec<workspace::ItemId>,
+        _window: &mut Window,
+        cx: &mut App,
+    ) -> Task<gpui::Result<()>> {
+        workspace::delete_unloaded_items(
+            alive_items,
+            workspace_id,
+            "git_graphs",
+            &persistence::GIT_GRAPHS,
+            cx,
+        )
+    }
+
+    fn deserialize(
+        project: Entity<Project>,
+        _: WeakEntity<Workspace>,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Task<gpui::Result<Entity<Self>>> {
+        if persistence::GIT_GRAPHS
+            .get_git_graph(item_id, workspace_id)
+            .ok()
+            .is_some_and(|is_open| is_open)
+        {
+            let git_graph = cx.new(|cx| GitGraph::new(project, window, cx));
+            Task::ready(Ok(git_graph))
+        } else {
+            Task::ready(Err(anyhow::anyhow!("No git graph to deserialize")))
+        }
+    }
+
+    fn serialize(
+        &mut self,
+        workspace: &mut Workspace,
+        item_id: workspace::ItemId,
+        _closing: bool,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Task<gpui::Result<()>>> {
+        let workspace_id = workspace.database_id()?;
+        Some(cx.background_spawn(async move {
+            persistence::GIT_GRAPHS
+                .save_git_graph(item_id, workspace_id, true)
+                .await
+        }))
+    }
+
+    fn should_serialize(&self, event: &Self::Event) -> bool {
+        event == &ItemEvent::UpdateTab
+    }
+}
+
+mod persistence {
+    use db::{
+        query,
+        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+        sqlez_macros::sql,
+    };
+    use workspace::WorkspaceDb;
+
+    pub struct GitGraphsDb(ThreadSafeConnection);
+
+    impl Domain for GitGraphsDb {
+        const NAME: &str = stringify!(GitGraphsDb);
+
+        const MIGRATIONS: &[&str] = (&[sql!(
+            CREATE TABLE git_graphs (
+                workspace_id INTEGER,
+                item_id INTEGER UNIQUE,
+                is_open INTEGER DEFAULT FALSE,
+
+                PRIMARY KEY(workspace_id, item_id),
+                FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+                ON DELETE CASCADE
+            ) STRICT;
+        )]);
+    }
+
+    db::static_connection!(GIT_GRAPHS, GitGraphsDb, [WorkspaceDb]);
+
+    impl GitGraphsDb {
+        query! {
+            pub async fn save_git_graph(
+                item_id: workspace::ItemId,
+                workspace_id: workspace::WorkspaceId,
+                is_open: bool
+            ) -> Result<()> {
+                INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, is_open)
+                VALUES (?, ?, ?)
+            }
+        }
+
+        query! {
+            pub fn get_git_graph(
+                item_id: workspace::ItemId,
+                workspace_id: workspace::WorkspaceId
+            ) -> Result<bool> {
+                SELECT is_open
+                FROM git_graphs
+                WHERE item_id = ? AND workspace_id = ?
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use anyhow::{Context, Result, bail};
+    use collections::{HashMap, HashSet};
+    use fs::FakeFs;
+    use git::Oid;
+    use git::repository::InitialGraphCommitData;
+    use gpui::TestAppContext;
+    use project::Project;
+    use rand::prelude::*;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use smallvec::{SmallVec, smallvec};
+    use std::path::Path;
+    use std::sync::Arc;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+    }
+
+    /// Generates a random commit DAG suitable for testing git graph rendering.
+    ///
+    /// The commits are ordered newest-first (like git log output), so:
+    /// - Index 0 = most recent commit (HEAD)
+    /// - Last index = oldest commit (root, has no parents)
+    /// - Parents of commit at index I must have index > I
+    ///
+    /// When `adversarial` is true, generates complex topologies with many branches
+    /// and octopus merges. Otherwise generates more realistic linear histories
+    /// with occasional branches.
+    fn generate_random_commit_dag(
+        rng: &mut StdRng,
+        num_commits: usize,
+        adversarial: bool,
+    ) -> Vec<Arc<InitialGraphCommitData>> {
+        if num_commits == 0 {
+            return Vec::new();
+        }
+
+        let mut commits: Vec<Arc<InitialGraphCommitData>> = Vec::with_capacity(num_commits);
+        let oids: Vec<Oid> = (0..num_commits).map(|_| Oid::random(rng)).collect();
+
+        for i in 0..num_commits {
+            let sha = oids[i];
+
+            let parents = if i == num_commits - 1 {
+                smallvec![]
+            } else {
+                generate_parents_from_oids(rng, &oids, i, num_commits, adversarial)
+            };
+
+            let ref_names = if i == 0 {
+                vec!["HEAD".into(), "main".into()]
+            } else if adversarial && rng.random_bool(0.1) {
+                vec![format!("branch-{}", i).into()]
+            } else {
+                Vec::new()
+            };
+
+            commits.push(Arc::new(InitialGraphCommitData {
+                sha,
+                parents,
+                ref_names,
+            }));
+        }
+
+        commits
+    }
+
+    fn generate_parents_from_oids(
+        rng: &mut StdRng,
+        oids: &[Oid],
+        current_idx: usize,
+        num_commits: usize,
+        adversarial: bool,
+    ) -> SmallVec<[Oid; 1]> {
+        let remaining = num_commits - current_idx - 1;
+        if remaining == 0 {
+            return smallvec![];
+        }
+
+        if adversarial {
+            let merge_chance = 0.4;
+            let octopus_chance = 0.15;
+
+            if remaining >= 3 && rng.random_bool(octopus_chance) {
+                let num_parents = rng.random_range(3..=remaining.min(5));
+                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
+                parent_indices.shuffle(rng);
+                parent_indices
+                    .into_iter()
+                    .take(num_parents)
+                    .map(|idx| oids[idx])
+                    .collect()
+            } else if remaining >= 2 && rng.random_bool(merge_chance) {
+                let mut parent_indices: Vec<usize> = (current_idx + 1..num_commits).collect();
+                parent_indices.shuffle(rng);
+                parent_indices
+                    .into_iter()
+                    .take(2)
+                    .map(|idx| oids[idx])
+                    .collect()
+            } else {
+                let parent_idx = rng.random_range(current_idx + 1..num_commits);
+                smallvec![oids[parent_idx]]
+            }
+        } else {
+            let merge_chance = 0.15;
+            let skip_chance = 0.1;
+
+            if remaining >= 2 && rng.random_bool(merge_chance) {
+                let first_parent = current_idx + 1;
+                let second_parent = rng.random_range(current_idx + 2..num_commits);
+                smallvec![oids[first_parent], oids[second_parent]]
+            } else if rng.random_bool(skip_chance) && remaining >= 2 {
+                let skip = rng.random_range(1..remaining.min(3));
+                smallvec![oids[current_idx + 1 + skip]]
+            } else {
+                smallvec![oids[current_idx + 1]]
+            }
+        }
+    }
+
+    fn build_oid_to_row_map(graph: &GraphData) -> HashMap<Oid, usize> {
+        graph
+            .commits
+            .iter()
+            .enumerate()
+            .map(|(idx, entry)| (entry.data.sha, idx))
+            .collect()
+    }
+
+    fn verify_commit_order(
+        graph: &GraphData,
+        commits: &[Arc<InitialGraphCommitData>],
+    ) -> Result<()> {
+        if graph.commits.len() != commits.len() {
+            bail!(
+                "Commit count mismatch: graph has {} commits, expected {}",
+                graph.commits.len(),
+                commits.len()
+            );
+        }
+
+        for (idx, (graph_commit, expected_commit)) in
+            graph.commits.iter().zip(commits.iter()).enumerate()
+        {
+            if graph_commit.data.sha != expected_commit.sha {
+                bail!(
+                    "Commit order mismatch at index {}: graph has {:?}, expected {:?}",
+                    idx,
+                    graph_commit.data.sha,
+                    expected_commit.sha
+                );
+            }
+        }
+
+        Ok(())
+    }
+
+    fn verify_line_endpoints(graph: &GraphData, oid_to_row: &HashMap<Oid, usize>) -> Result<()> {
+        for line in &graph.lines {
+            let child_row = *oid_to_row
+                .get(&line.child)
+                .context("Line references non-existent child commit")?;
+
+            let parent_row = *oid_to_row
+                .get(&line.parent)
+                .context("Line references non-existent parent commit")?;
+
+            if child_row >= parent_row {
+                bail!(
+                    "child_row ({}) must be < parent_row ({})",
+                    child_row,
+                    parent_row
+                );
+            }
+
+            if line.full_interval.start != child_row {
+                bail!(
+                    "full_interval.start ({}) != child_row ({})",
+                    line.full_interval.start,
+                    child_row
+                );
+            }
+
+            if line.full_interval.end != parent_row {
+                bail!(
+                    "full_interval.end ({}) != parent_row ({})",
+                    line.full_interval.end,
+                    parent_row
+                );
+            }
+
+            if let Some(last_segment) = line.segments.last() {
+                let segment_end_row = match last_segment {
+                    CommitLineSegment::Straight { to_row } => *to_row,
+                    CommitLineSegment::Curve { on_row, .. } => *on_row,
+                };
+
+                if segment_end_row != line.full_interval.end {
+                    bail!(
+                        "last segment ends at row {} but full_interval.end is {}",
+                        segment_end_row,
+                        line.full_interval.end
+                    );
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn verify_column_correctness(
+        graph: &GraphData,
+        oid_to_row: &HashMap<Oid, usize>,
+    ) -> Result<()> {
+        for line in &graph.lines {
+            let child_row = *oid_to_row
+                .get(&line.child)
+                .context("Line references non-existent child commit")?;
+
+            let parent_row = *oid_to_row
+                .get(&line.parent)
+                .context("Line references non-existent parent commit")?;
+
+            let child_lane = graph.commits[child_row].lane;
+            if line.child_column != child_lane {
+                bail!(
+                    "child_column ({}) != child's lane ({})",
+                    line.child_column,
+                    child_lane
+                );
+            }
+
+            let mut current_column = line.child_column;
+            for segment in &line.segments {
+                if let CommitLineSegment::Curve { to_column, .. } = segment {
+                    current_column = *to_column;
+                }
+            }
+
+            let parent_lane = graph.commits[parent_row].lane;
+            if current_column != parent_lane {
+                bail!(
+                    "ending column ({}) != parent's lane ({})",
+                    current_column,
+                    parent_lane
+                );
+            }
+        }
+
+        Ok(())
+    }
+
+    fn verify_segment_continuity(graph: &GraphData) -> Result<()> {
+        for line in &graph.lines {
+            if line.segments.is_empty() {
+                bail!("Line has no segments");
+            }
+
+            let mut current_row = line.full_interval.start;
+
+            for (idx, segment) in line.segments.iter().enumerate() {
+                let segment_end_row = match segment {
+                    CommitLineSegment::Straight { to_row } => *to_row,
+                    CommitLineSegment::Curve { on_row, .. } => *on_row,
+                };
+
+                if segment_end_row < current_row {
+                    bail!(
+                        "segment {} ends at row {} which is before current row {}",
+                        idx,
+                        segment_end_row,
+                        current_row
+                    );
+                }
+
+                current_row = segment_end_row;
+            }
+        }
+
+        Ok(())
+    }
+
+    fn verify_line_overlaps(graph: &GraphData) -> Result<()> {
+        for line in &graph.lines {
+            let child_row = line.full_interval.start;
+
+            let mut current_column = line.child_column;
+            let mut current_row = child_row;
+
+            for segment in &line.segments {
+                match segment {
+                    CommitLineSegment::Straight { to_row } => {
+                        for row in (current_row + 1)..*to_row {
+                            if row < graph.commits.len() {
+                                let commit_at_row = &graph.commits[row];
+                                if commit_at_row.lane == current_column {
+                                    bail!(
+                                        "straight segment from row {} to {} in column {} passes through commit {:?} at row {}",
+                                        current_row,
+                                        to_row,
+                                        current_column,
+                                        commit_at_row.data.sha,
+                                        row
+                                    );
+                                }
+                            }
+                        }
+                        current_row = *to_row;
+                    }
+                    CommitLineSegment::Curve {
+                        to_column, on_row, ..
+                    } => {
+                        current_column = *to_column;
+                        current_row = *on_row;
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn verify_coverage(graph: &GraphData) -> Result<()> {
+        let mut expected_edges: HashSet<(Oid, Oid)> = HashSet::default();
+        for entry in &graph.commits {
+            for parent in &entry.data.parents {
+                expected_edges.insert((entry.data.sha, *parent));
+            }
+        }
+
+        let mut found_edges: HashSet<(Oid, Oid)> = HashSet::default();
+        for line in &graph.lines {
+            let edge = (line.child, line.parent);
+
+            if !found_edges.insert(edge) {
+                bail!(
+                    "Duplicate line found for edge {:?} -> {:?}",
+                    line.child,
+                    line.parent
+                );
+            }
+
+            if !expected_edges.contains(&edge) {
+                bail!(
+                    "Orphan line found: {:?} -> {:?} is not in the commit graph",
+                    line.child,
+                    line.parent
+                );
+            }
+        }
+
+        for (child, parent) in &expected_edges {
+            if !found_edges.contains(&(*child, *parent)) {
+                bail!("Missing line for edge {:?} -> {:?}", child, parent);
+            }
+        }
+
+        assert_eq!(
+            expected_edges.symmetric_difference(&found_edges).count(),
+            0,
+            "The symmetric difference should be zero"
+        );
+
+        Ok(())
+    }
+
+    fn verify_merge_line_optimality(
+        graph: &GraphData,
+        oid_to_row: &HashMap<Oid, usize>,
+    ) -> Result<()> {
+        for line in &graph.lines {
+            let first_segment = line.segments.first();
+            let is_merge_line = matches!(
+                first_segment,
+                Some(CommitLineSegment::Curve {
+                    curve_kind: CurveKind::Merge,
+                    ..
+                })
+            );
+
+            if !is_merge_line {
+                continue;
+            }
+
+            let child_row = *oid_to_row
+                .get(&line.child)
+                .context("Line references non-existent child commit")?;
+
+            let parent_row = *oid_to_row
+                .get(&line.parent)
+                .context("Line references non-existent parent commit")?;
+
+            let parent_lane = graph.commits[parent_row].lane;
+
+            let Some(CommitLineSegment::Curve { to_column, .. }) = first_segment else {
+                continue;
+            };
+
+            let curves_directly_to_parent = *to_column == parent_lane;
+
+            if !curves_directly_to_parent {
+                continue;
+            }
+
+            let curve_row = child_row + 1;
+            let has_commits_in_path = graph.commits[curve_row..parent_row]
+                .iter()
+                .any(|c| c.lane == parent_lane);
+
+            if has_commits_in_path {
+                bail!(
+                    "Merge line from {:?} to {:?} curves directly to parent lane {} but there are commits in that lane between rows {} and {}",
+                    line.child,
+                    line.parent,
+                    parent_lane,
+                    curve_row,
+                    parent_row
+                );
+            }
+
+            let curve_ends_at_parent = curve_row == parent_row;
+
+            if curve_ends_at_parent {
+                if line.segments.len() != 1 {
+                    bail!(
+                        "Merge line from {:?} to {:?} curves directly to parent (curve_row == parent_row), but has {} segments instead of 1 [MergeCurve]",
+                        line.child,
+                        line.parent,
+                        line.segments.len()
+                    );
+                }
+            } else {
+                if line.segments.len() != 2 {
+                    bail!(
+                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but has {} segments instead of 2 [MergeCurve, Straight]",
+                        line.child,
+                        line.parent,
+                        line.segments.len()
+                    );
+                }
+
+                let is_straight_segment = matches!(
+                    line.segments.get(1),
+                    Some(CommitLineSegment::Straight { .. })
+                );
+
+                if !is_straight_segment {
+                    bail!(
+                        "Merge line from {:?} to {:?} curves directly to parent lane without overlap, but second segment is not a Straight segment",
+                        line.child,
+                        line.parent
+                    );
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn verify_all_invariants(
+        graph: &GraphData,
+        commits: &[Arc<InitialGraphCommitData>],
+    ) -> Result<()> {
+        let oid_to_row = build_oid_to_row_map(graph);
+
+        verify_commit_order(graph, commits).context("commit order")?;
+        verify_line_endpoints(graph, &oid_to_row).context("line endpoints")?;
+        verify_column_correctness(graph, &oid_to_row).context("column correctness")?;
+        verify_segment_continuity(graph).context("segment continuity")?;
+        verify_merge_line_optimality(graph, &oid_to_row).context("merge line optimality")?;
+        verify_coverage(graph).context("coverage")?;
+        verify_line_overlaps(graph).context("line overlaps")?;
+        Ok(())
+    }
+
+    #[test]
+    fn test_git_graph_merge_commits() {
+        let mut rng = StdRng::seed_from_u64(42);
+
+        let oid1 = Oid::random(&mut rng);
+        let oid2 = Oid::random(&mut rng);
+        let oid3 = Oid::random(&mut rng);
+        let oid4 = Oid::random(&mut rng);
+
+        let commits = vec![
+            Arc::new(InitialGraphCommitData {
+                sha: oid1,
+                parents: smallvec![oid2, oid3],
+                ref_names: vec!["HEAD".into()],
+            }),
+            Arc::new(InitialGraphCommitData {
+                sha: oid2,
+                parents: smallvec![oid4],
+                ref_names: vec![],
+            }),
+            Arc::new(InitialGraphCommitData {
+                sha: oid3,
+                parents: smallvec![oid4],
+                ref_names: vec![],
+            }),
+            Arc::new(InitialGraphCommitData {
+                sha: oid4,
+                parents: smallvec![],
+                ref_names: vec![],
+            }),
+        ];
+
+        let mut graph_data = GraphData::new(8);
+        graph_data.add_commits(&commits);
+
+        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
+            panic!("Graph invariant violation for merge commits:\n{}", error);
+        }
+    }
+
+    #[test]
+    fn test_git_graph_linear_commits() {
+        let mut rng = StdRng::seed_from_u64(42);
+
+        let oid1 = Oid::random(&mut rng);
+        let oid2 = Oid::random(&mut rng);
+        let oid3 = Oid::random(&mut rng);
+
+        let commits = vec![
+            Arc::new(InitialGraphCommitData {
+                sha: oid1,
+                parents: smallvec![oid2],
+                ref_names: vec!["HEAD".into()],
+            }),
+            Arc::new(InitialGraphCommitData {
+                sha: oid2,
+                parents: smallvec![oid3],
+                ref_names: vec![],
+            }),
+            Arc::new(InitialGraphCommitData {
+                sha: oid3,
+                parents: smallvec![],
+                ref_names: vec![],
+            }),
+        ];
+
+        let mut graph_data = GraphData::new(8);
+        graph_data.add_commits(&commits);
+
+        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
+            panic!("Graph invariant violation for linear commits:\n{}", error);
+        }
+    }
+
+    #[test]
+    fn test_git_graph_random_commits() {
+        for seed in 0..100 {
+            let mut rng = StdRng::seed_from_u64(seed);
+
+            let adversarial = rng.random_bool(0.2);
+            let num_commits = if adversarial {
+                rng.random_range(10..100)
+            } else {
+                rng.random_range(5..50)
+            };
+
+            let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
+
+            assert_eq!(
+                num_commits,
+                commits.len(),
+                "seed={}: Generate random commit dag didn't generate the correct amount of commits",
+                seed
+            );
+
+            let mut graph_data = GraphData::new(8);
+            graph_data.add_commits(&commits);
+
+            if let Err(error) = verify_all_invariants(&graph_data, &commits) {
+                panic!(
+                    "Graph invariant violation (seed={}, adversarial={}, num_commits={}):\n{:#}",
+                    seed, adversarial, num_commits, error
+                );
+            }
+        }
+    }
+
+    // The full integration test has less iterations because it's significantly slower
+    // than the random commit test
+    #[gpui::test(iterations = 5)]
+    async fn test_git_graph_random_integration(mut rng: StdRng, cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let adversarial = rng.random_bool(0.2);
+        let num_commits = if adversarial {
+            rng.random_range(10..100)
+        } else {
+            rng.random_range(5..50)
+        };
+
+        let commits = generate_random_commit_dag(&mut rng, num_commits, adversarial);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            Path::new("/project"),
+            json!({
+                ".git": {},
+                "file.txt": "content",
+            }),
+        )
+        .await;
+
+        fs.set_graph_commits(Path::new("/project/.git"), commits.clone());
+
+        let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+        cx.run_until_parked();
+
+        let repository = project.read_with(cx, |project, cx| {
+            project
+                .active_repository(cx)
+                .expect("should have a repository")
+        });
+
+        repository.update(cx, |repo, cx| {
+            repo.graph_data(
+                crate::LogSource::default(),
+                crate::LogOrder::default(),
+                0..usize::MAX,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        let graph_commits: Vec<Arc<InitialGraphCommitData>> = repository.update(cx, |repo, cx| {
+            repo.graph_data(
+                crate::LogSource::default(),
+                crate::LogOrder::default(),
+                0..usize::MAX,
+                cx,
+            )
+            .to_vec()
+        });
+
+        let mut graph_data = GraphData::new(8);
+        graph_data.add_commits(&graph_commits);
+
+        if let Err(error) = verify_all_invariants(&graph_data, &commits) {
+            panic!(
+                "Graph invariant violation (adversarial={}, num_commits={}):\n{:#}",
+                adversarial, num_commits, error
+            );
+        }
+    }
+}

crates/project/src/git_store.rs 🔗

@@ -30,8 +30,9 @@ use git::{
     parse_git_remote_url,
     repository::{
         Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions,
-        GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath,
-        ResetMode, UpstreamTrackingStatus, Worktree as GitWorktree,
+        GitRepository, GitRepositoryCheckpoint, GraphCommitData, InitialGraphCommitData, LogOrder,
+        LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode,
+        UpstreamTrackingStatus, Worktree as GitWorktree,
     },
     stash::{GitStash, StashEntry},
     status::{
@@ -252,6 +253,12 @@ pub struct MergeDetails {
     pub heads: Vec<Option<SharedString>>,
 }
 
+#[derive(Clone)]
+pub enum CommitDataState {
+    Loading,
+    Loaded(Arc<GraphCommitData>),
+}
+
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct RepositorySnapshot {
     pub id: RepositoryId,
@@ -275,6 +282,17 @@ pub struct JobInfo {
     pub message: SharedString,
 }
 
+struct GraphCommitDataHandler {
+    _task: Task<()>,
+    commit_data_request: smol::channel::Sender<Oid>,
+}
+
+enum GraphCommitHandlerState {
+    Starting,
+    Open(GraphCommitDataHandler),
+    Closed,
+}
+
 pub struct Repository {
     this: WeakEntity<Self>,
     snapshot: RepositorySnapshot,
@@ -290,6 +308,15 @@ pub struct Repository {
     askpass_delegates: Arc<Mutex<HashMap<u64, AskPassDelegate>>>,
     latest_askpass_id: u64,
     repository_state: Shared<Task<Result<RepositoryState, String>>>,
+    pub initial_graph_data: HashMap<
+        (LogOrder, LogSource),
+        (
+            Task<Result<(), SharedString>>,
+            Vec<Arc<InitialGraphCommitData>>,
+        ),
+    >,
+    graph_commit_data_handler: GraphCommitHandlerState,
+    commit_data: HashMap<Oid, CommitDataState>,
 }
 
 impl std::ops::Deref for Repository {
@@ -367,6 +394,7 @@ pub enum RepositoryEvent {
     BranchChanged,
     StashEntriesChanged,
     PendingOpsChanged { pending_ops: SumTree<PendingOps> },
+    GitGraphCountUpdated((LogOrder, LogSource), usize),
 }
 
 #[derive(Clone, Debug)]
@@ -375,6 +403,7 @@ pub struct JobsUpdated;
 #[derive(Debug)]
 pub enum GitStoreEvent {
     ActiveRepositoryChanged(Option<RepositoryId>),
+    /// Bool is true when the repository that's updated is the active repository
     RepositoryUpdated(RepositoryId, RepositoryEvent, bool),
     RepositoryAdded,
     RepositoryRemoved(RepositoryId),
@@ -3586,6 +3615,14 @@ impl Repository {
             })
             .shared();
 
+        cx.subscribe_self(|this, event: &RepositoryEvent, _| match event {
+            RepositoryEvent::BranchChanged | RepositoryEvent::MergeHeadsChanged => {
+                this.initial_graph_data.clear();
+            }
+            _ => {}
+        })
+        .detach();
+
         Repository {
             this: cx.weak_entity(),
             git_store,
@@ -3599,6 +3636,9 @@ impl Repository {
             job_sender,
             job_id: 0,
             active_jobs: Default::default(),
+            initial_graph_data: Default::default(),
+            commit_data: Default::default(),
+            graph_commit_data_handler: GraphCommitHandlerState::Closed,
         }
     }
 
@@ -3628,6 +3668,9 @@ impl Repository {
             latest_askpass_id: 0,
             active_jobs: Default::default(),
             job_id: 0,
+            initial_graph_data: Default::default(),
+            commit_data: Default::default(),
+            graph_commit_data_handler: GraphCommitHandlerState::Closed,
         }
     }
 
@@ -4191,6 +4234,208 @@ impl Repository {
         })
     }
 
+    pub fn graph_data(
+        &mut self,
+        log_source: LogSource,
+        log_order: LogOrder,
+        range: Range<usize>,
+        cx: &mut Context<Self>,
+    ) -> &[Arc<InitialGraphCommitData>] {
+        let initial_commit_data = &self
+            .initial_graph_data
+            .entry((log_order, log_source.clone()))
+            .or_insert_with(|| {
+                let state = self.repository_state.clone();
+                let log_source = log_source.clone();
+                (
+                    cx.spawn(async move |repository, cx| {
+                        let state = state.await;
+                        match state {
+                            Ok(RepositoryState::Local(LocalRepositoryState {
+                                backend, ..
+                            })) => {
+                                Self::local_git_graph_data(
+                                    repository, backend, log_source, log_order, cx,
+                                )
+                                .await
+                            }
+                            Ok(RepositoryState::Remote(_)) => {
+                                Err("Git graph is not supported for collab yet".into())
+                            }
+                            Err(e) => Err(SharedString::from(e)),
+                        }
+                    }),
+                    vec![],
+                )
+            })
+            .1;
+
+        let max_start = initial_commit_data.len().saturating_sub(1);
+        let max_end = initial_commit_data.len();
+        &initial_commit_data[range.start.min(max_start)..range.end.min(max_end)]
+    }
+
+    async fn local_git_graph_data(
+        this: WeakEntity<Self>,
+        backend: Arc<dyn GitRepository>,
+        log_source: LogSource,
+        log_order: LogOrder,
+        cx: &mut AsyncApp,
+    ) -> Result<(), SharedString> {
+        let (request_tx, request_rx) =
+            smol::channel::unbounded::<Vec<Arc<InitialGraphCommitData>>>();
+
+        let task = cx.background_executor().spawn({
+            let log_source = log_source.clone();
+            async move {
+                backend
+                    .initial_graph_data(log_source, log_order, request_tx)
+                    .await
+                    .map_err(|err| SharedString::from(err.to_string()))
+            }
+        });
+
+        let graph_data_key = (log_order, log_source.clone());
+
+        while let Ok(initial_graph_commit_data) = request_rx.recv().await {
+            this.update(cx, |repository, cx| {
+                let graph_data = repository
+                    .initial_graph_data
+                    .get_mut(&graph_data_key)
+                    .map(|(_, graph_data)| graph_data);
+                debug_assert!(
+                    graph_data.is_some(),
+                    "This task should be dropped if data doesn't exist"
+                );
+
+                if let Some(graph_data) = graph_data {
+                    graph_data.extend(initial_graph_commit_data);
+                    cx.emit(RepositoryEvent::GitGraphCountUpdated(
+                        graph_data_key.clone(),
+                        graph_data.len(),
+                    ));
+                }
+            })
+            .ok();
+        }
+
+        task.await?;
+
+        Ok(())
+    }
+
+    pub fn fetch_commit_data(&mut self, sha: Oid, cx: &mut Context<Self>) -> &CommitDataState {
+        if !self.commit_data.contains_key(&sha) {
+            match &self.graph_commit_data_handler {
+                GraphCommitHandlerState::Open(handler) => {
+                    if handler.commit_data_request.try_send(sha).is_ok() {
+                        let old_value = self.commit_data.insert(sha, CommitDataState::Loading);
+                        debug_assert!(old_value.is_none(), "We should never overwrite commit data");
+                    }
+                }
+                GraphCommitHandlerState::Closed => {
+                    self.open_graph_commit_data_handler(cx);
+                }
+                GraphCommitHandlerState::Starting => {}
+            }
+        }
+
+        self.commit_data
+            .get(&sha)
+            .unwrap_or(&CommitDataState::Loading)
+    }
+
+    fn open_graph_commit_data_handler(&mut self, cx: &mut Context<Self>) {
+        self.graph_commit_data_handler = GraphCommitHandlerState::Starting;
+
+        let state = self.repository_state.clone();
+        let (result_tx, result_rx) = smol::channel::bounded::<(Oid, GraphCommitData)>(64);
+        let (request_tx, request_rx) = smol::channel::unbounded::<Oid>();
+
+        let foreground_task = cx.spawn(async move |this, cx| {
+            while let Ok((sha, commit_data)) = result_rx.recv().await {
+                let result = this.update(cx, |this, cx| {
+                    let old_value = this
+                        .commit_data
+                        .insert(sha, CommitDataState::Loaded(Arc::new(commit_data)));
+                    debug_assert!(
+                        !matches!(old_value, Some(CommitDataState::Loaded(_))),
+                        "We should never overwrite commit data"
+                    );
+
+                    cx.notify();
+                });
+                if result.is_err() {
+                    break;
+                }
+            }
+
+            this.update(cx, |this, _cx| {
+                this.graph_commit_data_handler = GraphCommitHandlerState::Closed;
+            })
+            .ok();
+        });
+
+        let request_tx_for_handler = request_tx;
+        let background_executor = cx.background_executor().clone();
+
+        cx.background_spawn(async move {
+            let backend = match state.await {
+                Ok(RepositoryState::Local(LocalRepositoryState { backend, .. })) => backend,
+                Ok(RepositoryState::Remote(_)) => {
+                    log::error!("commit_data_reader not supported for remote repositories");
+                    return;
+                }
+                Err(error) => {
+                    log::error!("failed to get repository state: {error}");
+                    return;
+                }
+            };
+
+            let reader = match backend.commit_data_reader() {
+                Ok(reader) => reader,
+                Err(error) => {
+                    log::error!("failed to create commit data reader: {error:?}");
+                    return;
+                }
+            };
+
+            loop {
+                let timeout = background_executor.timer(std::time::Duration::from_secs(10));
+
+                futures::select_biased! {
+                    sha = futures::FutureExt::fuse(request_rx.recv()) => {
+                        let Ok(sha) = sha else {
+                            break;
+                        };
+
+                        match reader.read(sha).await {
+                            Ok(commit_data) => {
+                                if result_tx.send((sha, commit_data)).await.is_err() {
+                                    break;
+                                }
+                            }
+                            Err(error) => {
+                                log::error!("failed to read commit data for {sha}: {error:?}");
+                            }
+                        }
+                    }
+                    _ = futures::FutureExt::fuse(timeout) => {
+                        break;
+                    }
+                }
+            }
+
+            drop(result_tx);
+        })
+        .detach();
+
+        self.graph_commit_data_handler = GraphCommitHandlerState::Open(GraphCommitDataHandler {
+            _task: foreground_task,
+            commit_data_request: request_tx_for_handler,
+        });
+    }
+
     fn buffer_store(&self, cx: &App) -> Option<Entity<BufferStore>> {
         Some(self.git_store.upgrade()?.read(cx).buffer_store.clone())
     }

crates/ui/src/components/data_table.rs 🔗

@@ -723,6 +723,7 @@ impl TableWidths {
 #[derive(RegisterComponent, IntoElement)]
 pub struct Table {
     striped: bool,
+    show_row_borders: bool,
     width: Option<Length>,
     headers: Option<TableRow<AnyElement>>,
     rows: TableContents,
@@ -741,6 +742,7 @@ impl Table {
         Self {
             cols,
             striped: false,
+            show_row_borders: true,
             width: None,
             headers: None,
             rows: TableContents::Vec(Vec::new()),
@@ -804,6 +806,12 @@ impl Table {
         self
     }
 
+    /// Hides the border lines between rows
+    pub fn hide_row_borders(mut self) -> Self {
+        self.show_row_borders = false;
+        self
+    }
+
     /// Sets the width of the table.
     /// Will enable horizontal scrolling if [`Self::interactable`] is also called.
     pub fn width(mut self, width: impl Into<Length>) -> Self {
@@ -941,7 +949,7 @@ pub fn render_table_row(
         .size_full()
         .when_some(bg, |row, bg| row.bg(bg))
         .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6)))
-        .when(!is_striped, |row| {
+        .when(!is_striped && table_context.show_row_borders, |row| {
             row.border_b_1()
                 .border_color(transparent_black())
                 .when(!is_last, |row| row.border_color(cx.theme().colors().border))
@@ -1046,6 +1054,7 @@ pub fn render_table_header(
 #[derive(Clone)]
 pub struct TableRenderContext {
     pub striped: bool,
+    pub show_row_borders: bool,
     pub total_row_count: usize,
     pub column_widths: Option<TableRow<Length>>,
     pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
@@ -1056,6 +1065,7 @@ impl TableRenderContext {
     fn new(table: &Table, cx: &App) -> Self {
         Self {
             striped: table.striped,
+            show_row_borders: table.show_row_borders,
             total_row_count: table.rows.len(),
             column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
             map_row: table.map_row.clone(),

crates/zed/Cargo.toml 🔗

@@ -110,6 +110,7 @@ file_finder.workspace = true
 fs.workspace = true
 futures.workspace = true
 git.workspace = true
+git_graph.workspace = true
 git_hosting_providers.workspace = true
 git_ui.workspace = true
 go_to_line.workspace = true

crates/zed/src/main.rs 🔗

@@ -681,6 +681,7 @@ fn main() {
         notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         collab_ui::init(&app_state, cx);
         git_ui::init(cx);
+        git_graph::init(cx);
         feedback::init(cx);
         markdown_preview::init(cx);
         svg_preview::init(cx);

crates/zed/src/zed.rs 🔗

@@ -4829,6 +4829,7 @@ mod tests {
                 "feedback",
                 "file_finder",
                 "git",
+                "git_graph",
                 "git_onboarding",
                 "git_panel",
                 "git_picker",
@@ -5025,6 +5026,7 @@ mod tests {
             language_model::init(app_state.client.clone(), cx);
             language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
             web_search::init(cx);
+            git_graph::init(cx);
             web_search_providers::init(app_state.client.clone(), cx);
             let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx);
             project::AgentRegistryStore::init_global(cx);