From 7ba0bda2c1f6302f120073aa39499df44b30a500 Mon Sep 17 00:00:00 2001
From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Date: Thu, 22 Jan 2026 20:53:23 -0500
Subject: [PATCH] git: Add graph support (#44434)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
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
Co-authored-by: Cole Miller
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
---
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, 3098 insertions(+), 7 deletions(-)
create mode 100644 crates/git_graph/Cargo.toml
create mode 120000 crates/git_graph/LICENSE-GPL
create mode 100644 crates/git_graph/src/git_graph.rs
diff --git a/Cargo.lock b/Cargo.lock
index 628074a875a4a7d3ac0fea87dc5b1cf3bd85fcf1..97900b60263db0e58348b3d62bc3fbacecd31d78 100644
--- a/Cargo.lock
+++ b/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",
diff --git a/Cargo.toml b/Cargo.toml
index c4a6648c51370b6de59283481e9f7b4e425e6b10..7e40776f4a395afd120c900ca3da2230a3bef48e 100644
--- a/Cargo.toml
+++ b/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" }
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index eda6fcec200cef450ed4c4243e2b9ad3656f0f0d..2057cd1d85958719255b46d441a31a80be4a95d0 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/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,
pub simulated_index_write_error_message: Option,
pub refs: HashMap,
+ pub graph_commits: Vec>,
}
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>>,
+ ) -> 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 {
+ anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
+ }
}
diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs
index ea09aa27abd1a8d0d1d9a6f587fe24d90e5f0d1f..f8eed421b582d462f0a69436aba1533e749a9cf0 100644
--- a/crates/fs/src/fs.rs
+++ b/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>) {
+ 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)]) {
diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml
index 39b68758744b5837c432b71c8e8bacd96a1da708..4d96312e274b3934e0d1ae8aa1f16f235d30a59f 100644
--- a/crates/git/Cargo.toml
+++ b/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
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index e2475dfc10ed08c719703d487e6a375fffec20ff..c727f7e9f6595cf0f46fa686641ec8465da845ad 100644
--- a/crates/git/src/repository.rs
+++ b/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,
+}
+
+struct CommitDataRequest {
+ sha: Oid,
+ response_tx: oneshot::Sender>,
+}
+
+pub struct CommitDataReader {
+ request_tx: smol::channel::Sender,
+ _task: Task<()>,
+}
+
+impl CommitDataReader {
+ pub async fn read(&self, sha: Oid) -> Result {
+ 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 {
+ 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::() {
+ 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