git: Add graph support (#44434)
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>
This commit is contained in:
1 parent
fa3e8bf7e3
commit
c51f7b8a66
14 files changed
+3098
-7
No files matched your search
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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)]) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"] }
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user