Show progress while cloning repositories (#63239)

## Summary

- Reuse the existing Zed clone activity indicator, which previously
showed only a static `Cloning <URL>` message.
- Add filesystem job updates so the existing indicator can change while
a job is running.
- Stream exact `git clone --progress` messages into that indicator
without maintaining a phase allowlist.
- Preserve the final Git stderr message when cloning fails.


https://github.com/user-attachments/assets/a9b8cbfd-b87f-4d83-a24b-992eb1c4de1f

## Testing

- `cargo test -p fs git_clone_progress::tests -- --nocapture`
- `CCACHE_DISABLE=1 ./script/clippy -p fs -p activity_indicator`
- Manually cloned a repository and verified live Git progress in the
activity indicator.

Release Notes:

- Improved repository cloning by showing live Git progress in the
activity indicator.
This commit is contained in:
itsfuad authored and GitHub committed 2026-09-04 10:39:14 +00:00
1 parent 83ffa665ed
commit 48628b1970
3 files changed
+182 -11

No files matched your search

@@ -102,11 +102,17 @@ impl ActivityIndicator {
this.update(cx, |this: &mut ActivityIndicator, cx| {
match job_event {
fs::JobEvent::Started { info } => {
this.fs_jobs.retain(|j| j.id != info.id);
this.fs_jobs.retain(|job| job.id != info.id);
this.fs_jobs.push(info);
}
fs::JobEvent::Updated { id, message } => {
if let Some(job) = this.fs_jobs.iter_mut().find(|job| job.id == id)
{
job.message = message;
}
}
fs::JobEvent::Completed { id } => {
this.fs_jobs.retain(|j| j.id != id);
this.fs_jobs.retain(|job| job.id != id);
}
}
cx.notify();
+33 -9
View File
@@ -1,4 +1,5 @@
pub mod fs_watcher;
mod git_clone_progress;
pub use fs_watcher::requires_poll_watcher;
@@ -18,7 +19,7 @@ use gpui::ReadGlobal as _;
use gpui::SharedString;
#[cfg(unix)]
use std::ffi::CString;
use util::command::new_command;
use util::command::{Stdio, new_command};
#[cfg(unix)]
use std::os::fd::{AsFd, AsRawFd};
@@ -326,6 +327,7 @@ pub struct JobInfo {
#[derive(Debug, Clone)]
pub enum JobEvent {
Started { info: JobInfo },
Updated { id: JobId, message: SharedString },
Completed { id: JobId },
}
@@ -350,6 +352,18 @@ impl JobTracker {
}
Self { id, subscribers }
}
fn update(&self, message: SharedString) {
let mut subscribers = self.subscribers.lock();
subscribers.retain(|sender| {
sender
.unbounded_send(JobEvent::Updated {
id: self.id,
message: message.clone(),
})
.is_ok()
});
}
}
impl Drop for JobTracker {
@@ -1242,18 +1256,28 @@ impl Fs for RealFs {
message: SharedString::from(format!("Cloning {}", repo_url)),
};
let _job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone());
let output = new_command("git")
let job_tracker = JobTracker::new(job_info, self.job_event_subscribers.clone());
let mut child = new_command("git")
.current_dir(abs_work_directory)
.args(&["clone", repo_url])
.output()
.await?;
.args(["clone", "--progress", repo_url])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stderr = child
.stderr
.take()
.context("failed to read git clone progress")?;
let stderr_output = git_clone_progress::read(stderr, |message| {
job_tracker.update(message.into());
})
.await?;
let status = child.status().await?;
if !output.status.success() {
if !status.success() {
anyhow::bail!(
"git clone failed: {}",
String::from_utf8_lossy(&output.stderr).trim_end()
git_clone_progress::failure_message(&stderr_output)
);
}
+141
View File
@@ -0,0 +1,141 @@
use futures::AsyncRead;
use smol::io::AsyncReadExt;
use std::io;
pub(crate) async fn read(
mut stderr: impl AsyncRead + Unpin,
mut on_progress: impl FnMut(String),
) -> io::Result<Vec<u8>> {
let mut stderr_output = Vec::new();
let mut read_buffer = [0; 8192];
let mut progress = GitCloneProgress::new();
loop {
let bytes_read = stderr.read(&mut read_buffer).await?;
if bytes_read == 0 {
break;
}
let bytes = &read_buffer[..bytes_read];
stderr_output.extend_from_slice(bytes);
progress.push(bytes, &mut on_progress);
}
progress.finish(on_progress);
Ok(stderr_output)
}
pub(crate) fn failure_message(stderr: &[u8]) -> String {
String::from_utf8_lossy(stderr)
.split(['\r', '\n'])
.rev()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("unknown error")
.to_owned()
}
struct GitCloneProgress {
pending: Vec<u8>,
last_message: Option<String>,
}
impl GitCloneProgress {
fn new() -> Self {
Self {
pending: Vec::new(),
last_message: None,
}
}
fn push(&mut self, bytes: &[u8], mut on_progress: impl FnMut(String)) {
for byte in bytes {
if matches!(*byte, b'\r' | b'\n') {
self.flush(&mut on_progress);
} else {
self.pending.push(*byte);
}
}
}
fn finish(&mut self, mut on_progress: impl FnMut(String)) {
self.flush(&mut on_progress);
}
fn flush(&mut self, on_progress: &mut impl FnMut(String)) {
if self.pending.is_empty() {
return;
}
let message = String::from_utf8_lossy(&self.pending).trim().to_owned();
if !message.is_empty() && self.last_message.as_ref() != Some(&message) {
self.last_message = Some(message.clone());
on_progress(message);
}
self.pending.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_fragmented_progress_and_suppresses_duplicates() {
let mut progress = GitCloneProgress::new();
let mut messages = Vec::new();
progress.push(
b"remote: Enumerating objects: 100, done.\rremote: Count",
|message| messages.push(message),
);
progress.push(
b"ing objects: 42% (42/100)\rReceiving objects: 100% (100/100)\rReceiving objects: 100% (100/100)\n",
|message| messages.push(message),
);
progress.push(b"Updating files: 75% (3/4)", |message| {
messages.push(message)
});
progress.finish(|message| messages.push(message));
assert_eq!(
messages,
[
"remote: Enumerating objects: 100, done.",
"remote: Counting objects: 42% (42/100)",
"Receiving objects: 100% (100/100)",
"Updating files: 75% (3/4)",
]
);
}
#[test]
fn forwards_unrecognized_git_output() {
let mut progress = GitCloneProgress::new();
let mut messages = Vec::new();
progress.push(b"Cloning into 'repository'...\n", |message| {
messages.push(message)
});
progress.push(b"A future Git progress phase: 12%\r", |message| {
messages.push(message)
});
assert_eq!(
messages,
[
"Cloning into 'repository'...",
"A future Git progress phase: 12%"
]
);
}
#[test]
fn extracts_the_last_failure_line() {
assert_eq!(
failure_message(
b"Receiving objects: 42% (42/100)\rfatal: unable to access repository\n"
),
"fatal: unable to access repository"
);
assert_eq!(failure_message(b""), "unknown error");
}
}