git: Make long-running git staging snappy in git panel (#42149)

Previously, staging a large file in the git panel would block the UI
items until that operation finished. This is due to the fact that
staging is a git op that is locked globally by git (per repo) meaning
only one op that is modifying the git index can run at any one time. In
order to make the UI snappy while letting any pending git staging jobs
to finish in the background, we track their progress via `PendingOps`
indexed by git entry path. We have already had a concept of pending
operations however they existed at the UI layer in the `GitPanel`
abstraction. This PR moves and augments `PendingOps` into the model
`Repository` in `git_store` which seems like a more natural place for
tracking running git jobs/operations. Thanks to this, pending ops are
now stored in a `SumTree` indexed by git entry path part of the
`Repository` snapshot, which makes for efficient access from the UI.

Release Notes:

- Improved UI responsiveness when staging/unstaging large files in the
git panel
This commit is contained in:
Jakub Konka
2025-11-07 07:34:06 +01:00
committed by GitHub
parent 5044e6ac1d
commit 00eafe63d9
5 changed files with 1000 additions and 319 deletions
+254 -119
View File
@@ -1,6 +1,7 @@
pub mod branch_diff;
mod conflict_set;
pub mod git_traversal;
pub mod pending_op;
use crate::{
ProjectEnvironment, ProjectItem, ProjectPath,
@@ -16,7 +17,10 @@ pub use conflict_set::{ConflictRegion, ConflictSet, ConflictSetSnapshot, Conflic
use fs::Fs;
use futures::{
FutureExt, StreamExt,
channel::{mpsc, oneshot},
channel::{
mpsc,
oneshot::{self, Canceled},
},
future::{self, Shared},
stream::FuturesOrdered,
};
@@ -44,6 +48,7 @@ use language::{
proto::{deserialize_version, serialize_version},
};
use parking_lot::Mutex;
use pending_op::{PendingOp, PendingOpId, PendingOps};
use postage::stream::Stream as _;
use rpc::{
AnyProtoClient, TypedEnvelope,
@@ -248,6 +253,7 @@ pub struct MergeDetails {
pub struct RepositorySnapshot {
pub id: RepositoryId,
pub statuses_by_path: SumTree<StatusEntry>,
pub pending_ops_by_path: SumTree<PendingOps>,
pub work_directory_abs_path: Arc<Path>,
pub path_style: PathStyle,
pub branch: Option<Branch>,
@@ -311,6 +317,9 @@ pub enum RepositoryEvent {
MergeHeadsChanged,
BranchChanged,
StashEntriesChanged,
PendingOpsChanged {
pending_ops: SumTree<pending_op::PendingOps>,
},
}
#[derive(Clone, Debug)]
@@ -338,7 +347,7 @@ pub struct GitJob {
#[derive(PartialEq, Eq)]
enum GitJobKey {
WriteIndex(RepoPath),
WriteIndex(Vec<RepoPath>),
ReloadBufferDiffBases,
RefreshStatuses,
ReloadGitState,
@@ -2161,7 +2170,7 @@ impl GitStore {
.update(&mut cx, |repository_handle, cx| {
repository_handle.checkout_files(&envelope.payload.commit, paths, cx)
})?
.await??;
.await?;
Ok(proto::Ack {})
}
@@ -2954,6 +2963,7 @@ impl RepositorySnapshot {
Self {
id,
statuses_by_path: Default::default(),
pending_ops_by_path: Default::default(),
work_directory_abs_path,
branch: None,
head_commit: None,
@@ -3081,6 +3091,12 @@ impl RepositorySnapshot {
.cloned()
}
pub fn pending_ops_for_path(&self, path: &RepoPath) -> Option<PendingOps> {
self.pending_ops_by_path
.get(&PathKey(path.0.clone()), ())
.cloned()
}
pub fn abs_path_to_repo_path(&self, abs_path: &Path) -> Option<RepoPath> {
Self::abs_path_to_repo_path_inner(&self.work_directory_abs_path, abs_path, self.path_style)
}
@@ -3636,37 +3652,50 @@ impl Repository {
&mut self,
commit: &str,
paths: Vec<RepoPath>,
_cx: &mut App,
) -> oneshot::Receiver<Result<()>> {
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let commit = commit.to_string();
let id = self.id;
self.send_job(
Some(format!("git checkout {}", commit).into()),
move |git_repo, _| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => {
backend
.checkout_files(commit, paths, environment.clone())
.await
}
RepositoryState::Remote { project_id, client } => {
client
.request(proto::GitCheckoutFiles {
project_id: project_id.0,
repository_id: id.to_proto(),
commit,
paths: paths.into_iter().map(|p| p.to_proto()).collect(),
})
.await?;
self.spawn_job_with_tracking(
paths.clone(),
pending_op::GitStatus::Reverted,
cx,
async move |this, cx| {
this.update(cx, |this, _cx| {
this.send_job(
Some(format!("git checkout {}", commit).into()),
move |git_repo, _| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => {
backend
.checkout_files(commit, paths, environment.clone())
.await
}
RepositoryState::Remote { project_id, client } => {
client
.request(proto::GitCheckoutFiles {
project_id: project_id.0,
repository_id: id.to_proto(),
commit,
paths: paths
.into_iter()
.map(|p| p.to_proto())
.collect(),
})
.await?;
Ok(())
}
}
Ok(())
}
}
},
)
})?
.await?
},
)
}
@@ -3796,7 +3825,7 @@ impl Repository {
}
pub fn stage_entries(
&self,
&mut self,
entries: Vec<RepoPath>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
@@ -3811,54 +3840,54 @@ impl Repository {
.collect::<Vec<_>>()
.join(" ");
let status = format!("git add {paths}");
let job_key = match entries.len() {
1 => Some(GitJobKey::WriteIndex(entries[0].clone())),
_ => None,
};
let job_key = GitJobKey::WriteIndex(entries.clone());
cx.spawn(async move |this, cx| {
for save_task in save_tasks {
save_task.await?;
}
self.spawn_job_with_tracking(
entries.clone(),
pending_op::GitStatus::Staged,
cx,
async move |this, cx| {
for save_task in save_tasks {
save_task.await?;
}
this.update(cx, |this, _| {
this.send_keyed_job(
job_key,
Some(status.into()),
move |git_repo, _cx| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => backend.stage_paths(entries, environment.clone()).await,
RepositoryState::Remote { project_id, client } => {
client
.request(proto::Stage {
project_id: project_id.0,
repository_id: id.to_proto(),
paths: entries
.into_iter()
.map(|repo_path| repo_path.to_proto())
.collect(),
})
.await
.context("sending stage request")?;
this.update(cx, |this, _| {
this.send_keyed_job(
Some(job_key),
Some(status.into()),
move |git_repo, _cx| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => backend.stage_paths(entries, environment.clone()).await,
RepositoryState::Remote { project_id, client } => {
client
.request(proto::Stage {
project_id: project_id.0,
repository_id: id.to_proto(),
paths: entries
.into_iter()
.map(|repo_path| repo_path.to_proto())
.collect(),
})
.await
.context("sending stage request")?;
Ok(())
Ok(())
}
}
}
},
)
})?
.await??;
Ok(())
})
},
)
})?
.await?
},
)
}
pub fn unstage_entries(
&self,
&mut self,
entries: Vec<RepoPath>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<()>> {
@@ -3873,66 +3902,88 @@ impl Repository {
.collect::<Vec<_>>()
.join(" ");
let status = format!("git reset {paths}");
let job_key = match entries.len() {
1 => Some(GitJobKey::WriteIndex(entries[0].clone())),
_ => None,
};
let job_key = GitJobKey::WriteIndex(entries.clone());
cx.spawn(async move |this, cx| {
for save_task in save_tasks {
save_task.await?;
}
self.spawn_job_with_tracking(
entries.clone(),
pending_op::GitStatus::Unstaged,
cx,
async move |this, cx| {
for save_task in save_tasks {
save_task.await?;
}
this.update(cx, |this, _| {
this.send_keyed_job(
job_key,
Some(status.into()),
move |git_repo, _cx| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => backend.unstage_paths(entries, environment).await,
RepositoryState::Remote { project_id, client } => {
client
.request(proto::Unstage {
project_id: project_id.0,
repository_id: id.to_proto(),
paths: entries
.into_iter()
.map(|repo_path| repo_path.to_proto())
.collect(),
})
.await
.context("sending unstage request")?;
this.update(cx, |this, _| {
this.send_keyed_job(
Some(job_key),
Some(status.into()),
move |git_repo, _cx| async move {
match git_repo {
RepositoryState::Local {
backend,
environment,
..
} => backend.unstage_paths(entries, environment).await,
RepositoryState::Remote { project_id, client } => {
client
.request(proto::Unstage {
project_id: project_id.0,
repository_id: id.to_proto(),
paths: entries
.into_iter()
.map(|repo_path| repo_path.to_proto())
.collect(),
})
.await
.context("sending unstage request")?;
Ok(())
Ok(())
}
}
}
},
)
})?
.await??;
Ok(())
})
},
)
})?
.await?
},
)
}
pub fn stage_all(&self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
pub fn stage_all(&mut self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
let to_stage = self
.cached_status()
.filter(|entry| !entry.status.staging().is_fully_staged())
.map(|entry| entry.repo_path)
.filter_map(|entry| {
if let Some(ops) = self.pending_ops_for_path(&entry.repo_path) {
if ops.staging() || ops.staged() {
None
} else {
Some(entry.repo_path)
}
} else if entry.status.staging().has_staged() {
None
} else {
Some(entry.repo_path)
}
})
.collect();
self.stage_entries(to_stage, cx)
}
pub fn unstage_all(&self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
pub fn unstage_all(&mut self, cx: &mut Context<Self>) -> Task<anyhow::Result<()>> {
let to_unstage = self
.cached_status()
.filter(|entry| entry.status.staging().has_staged())
.map(|entry| entry.repo_path)
.filter_map(|entry| {
if let Some(ops) = self.pending_ops_for_path(&entry.repo_path) {
if !ops.staging() && !ops.staged() {
None
} else {
Some(entry.repo_path)
}
} else if entry.status.staging().has_unstaged() {
None
} else {
Some(entry.repo_path)
}
})
.collect();
self.unstage_entries(to_unstage, cx)
}
@@ -4368,7 +4419,7 @@ impl Repository {
let this = cx.weak_entity();
let git_store = self.git_store.clone();
self.send_keyed_job(
Some(GitJobKey::WriteIndex(path.clone())),
Some(GitJobKey::WriteIndex(vec![path.clone()])),
None,
move |git_repo, mut cx| async move {
log::debug!(
@@ -5199,6 +5250,67 @@ impl Repository {
pub fn barrier(&mut self) -> oneshot::Receiver<()> {
self.send_job(None, |_, _| async {})
}
fn spawn_job_with_tracking<AsyncFn>(
&mut self,
paths: Vec<RepoPath>,
git_status: pending_op::GitStatus,
cx: &mut Context<Self>,
f: AsyncFn,
) -> Task<Result<()>>
where
AsyncFn: AsyncFnOnce(WeakEntity<Repository>, &mut AsyncApp) -> Result<()> + 'static,
{
let ids = self.new_pending_ops_for_paths(paths, git_status);
cx.spawn(async move |this, cx| {
let (job_status, result) = match f(this.clone(), cx).await {
Ok(()) => (pending_op::JobStatus::Finished, Ok(())),
Err(err) if err.is::<Canceled>() => (pending_op::JobStatus::Skipped, Ok(())),
Err(err) => (pending_op::JobStatus::Error, Err(err)),
};
this.update(cx, |this, _| {
let mut edits = Vec::with_capacity(ids.len());
for (id, entry) in ids {
if let Some(mut ops) = this.snapshot.pending_ops_for_path(&entry) {
if let Some(op) = ops.op_by_id_mut(id) {
op.job_status = job_status;
}
edits.push(sum_tree::Edit::Insert(ops));
}
}
this.snapshot.pending_ops_by_path.edit(edits, ());
})?;
result
})
}
fn new_pending_ops_for_paths(
&mut self,
paths: Vec<RepoPath>,
git_status: pending_op::GitStatus,
) -> Vec<(PendingOpId, RepoPath)> {
let mut edits = Vec::with_capacity(paths.len());
let mut ids = Vec::with_capacity(paths.len());
for path in paths {
let mut ops = self
.snapshot
.pending_ops_for_path(&path)
.unwrap_or_else(|| PendingOps::new(&path));
let id = ops.max_id() + 1;
ops.ops.push(PendingOp {
id,
git_status,
job_status: pending_op::JobStatus::Running,
});
edits.push(sum_tree::Edit::Insert(ops));
ids.push((id, path));
}
self.snapshot.pending_ops_by_path.edit(edits, ());
ids
}
}
fn get_permalink_in_rust_registry_src(
@@ -5464,6 +5576,28 @@ async fn compute_snapshot(
MergeDetails::load(&backend, &statuses_by_path, &prev_snapshot).await?;
log::debug!("new merge details (changed={merge_heads_changed:?}): {merge_details:?}");
let pending_ops_by_path = SumTree::from_iter(
prev_snapshot.pending_ops_by_path.iter().filter_map(|ops| {
let inner_ops: Vec<PendingOp> =
ops.ops.iter().filter(|op| op.running()).cloned().collect();
if inner_ops.is_empty() {
None
} else {
Some(PendingOps {
repo_path: ops.repo_path.clone(),
ops: inner_ops,
})
}
}),
(),
);
if pending_ops_by_path != prev_snapshot.pending_ops_by_path {
events.push(RepositoryEvent::PendingOpsChanged {
pending_ops: prev_snapshot.pending_ops_by_path.clone(),
})
}
if merge_heads_changed {
events.push(RepositoryEvent::MergeHeadsChanged);
}
@@ -5489,6 +5623,7 @@ async fn compute_snapshot(
let snapshot = RepositorySnapshot {
id,
statuses_by_path,
pending_ops_by_path,
work_directory_abs_path,
path_style: prev_snapshot.path_style,
scan_id: prev_snapshot.scan_id + 1,
+147
View File
@@ -0,0 +1,147 @@
use git::repository::RepoPath;
use std::ops::Add;
use sum_tree::{ContextLessSummary, Item, KeyedItem};
use worktree::{PathKey, PathSummary};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GitStatus {
Staged,
Unstaged,
Reverted,
Unchanged,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JobStatus {
Running,
Finished,
Skipped,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingOps {
pub repo_path: RepoPath,
pub ops: Vec<PendingOp>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PendingOp {
pub id: PendingOpId,
pub git_status: GitStatus,
pub job_status: JobStatus,
}
#[derive(Clone, Debug)]
pub struct PendingOpsSummary {
pub staged_count: usize,
pub staging_count: usize,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct PendingOpId(pub u16);
impl Item for PendingOps {
type Summary = PathSummary<PendingOpsSummary>;
fn summary(&self, _cx: ()) -> Self::Summary {
PathSummary {
max_path: self.repo_path.0.clone(),
item_summary: PendingOpsSummary {
staged_count: self.staged() as usize,
staging_count: self.staging() as usize,
},
}
}
}
impl ContextLessSummary for PendingOpsSummary {
fn zero() -> Self {
Self {
staged_count: 0,
staging_count: 0,
}
}
fn add_summary(&mut self, summary: &Self) {
self.staged_count += summary.staged_count;
self.staging_count += summary.staging_count;
}
}
impl KeyedItem for PendingOps {
type Key = PathKey;
fn key(&self) -> Self::Key {
PathKey(self.repo_path.0.clone())
}
}
impl Add<u16> for PendingOpId {
type Output = PendingOpId;
fn add(self, rhs: u16) -> Self::Output {
Self(self.0 + rhs)
}
}
impl From<u16> for PendingOpId {
fn from(id: u16) -> Self {
Self(id)
}
}
impl PendingOps {
pub fn new(path: &RepoPath) -> Self {
Self {
repo_path: path.clone(),
ops: Vec::new(),
}
}
pub fn max_id(&self) -> PendingOpId {
self.ops.last().map(|op| op.id).unwrap_or_default()
}
pub fn op_by_id(&self, id: PendingOpId) -> Option<&PendingOp> {
self.ops.iter().find(|op| op.id == id)
}
pub fn op_by_id_mut(&mut self, id: PendingOpId) -> Option<&mut PendingOp> {
self.ops.iter_mut().find(|op| op.id == id)
}
/// File is staged if the last job is finished and has status Staged.
pub fn staged(&self) -> bool {
if let Some(last) = self.ops.last() {
if last.git_status == GitStatus::Staged && last.job_status == JobStatus::Finished {
return true;
}
}
false
}
/// File is staged if the last job is not finished and has status Staged.
pub fn staging(&self) -> bool {
if let Some(last) = self.ops.last() {
if last.git_status == GitStatus::Staged && last.job_status != JobStatus::Finished {
return true;
}
}
false
}
}
impl PendingOp {
pub fn running(&self) -> bool {
self.job_status == JobStatus::Running
}
pub fn finished(&self) -> bool {
matches!(self.job_status, JobStatus::Finished | JobStatus::Skipped)
}
pub fn error(&self) -> bool {
self.job_status == JobStatus::Error
}
}
+440 -2
View File
@@ -2,7 +2,7 @@
use crate::{
Event,
git_store::{GitStoreEvent, RepositoryEvent, StatusEntry},
git_store::{GitStoreEvent, RepositoryEvent, StatusEntry, pending_op},
task_inventory::TaskContexts,
task_store::TaskSettingsLocation,
*,
@@ -20,7 +20,7 @@ use git::{
status::{StatusCode, TrackedStatus},
};
use git2::RepositoryInitOptions;
use gpui::{App, BackgroundExecutor, SemanticVersion, UpdateGlobal};
use gpui::{App, BackgroundExecutor, FutureExt, SemanticVersion, UpdateGlobal};
use itertools::Itertools;
use language::{
Diagnostic, DiagnosticEntry, DiagnosticEntryRef, DiagnosticSet, DiagnosticSourceKind,
@@ -50,6 +50,7 @@ use std::{
sync::{Arc, OnceLock},
task::Poll,
};
use sum_tree::SumTree;
use task::{ResolvedTask, ShellKind, TaskContext};
use unindent::Unindent as _;
use util::{
@@ -8369,6 +8370,443 @@ async fn test_git_status_postprocessing(cx: &mut gpui::TestAppContext) {
});
}
#[track_caller]
/// We merge lhs into rhs.
fn merge_pending_ops_snapshots(
source: Vec<pending_op::PendingOps>,
mut target: Vec<pending_op::PendingOps>,
) -> Vec<pending_op::PendingOps> {
for s_ops in source {
if let Some(idx) = target.iter().zip(0..).find_map(|(ops, idx)| {
if ops.repo_path == s_ops.repo_path {
Some(idx)
} else {
None
}
}) {
let t_ops = &mut target[idx];
for s_op in s_ops.ops {
if let Some(op_idx) = t_ops
.ops
.iter()
.zip(0..)
.find_map(|(op, idx)| if op.id == s_op.id { Some(idx) } else { None })
{
let t_op = &mut t_ops.ops[op_idx];
match (s_op.job_status, t_op.job_status) {
(pending_op::JobStatus::Running, _) => {}
(s_st, pending_op::JobStatus::Running) => t_op.job_status = s_st,
(s_st, t_st) if s_st == t_st => {}
_ => unreachable!(),
}
} else {
t_ops.ops.push(s_op);
}
}
t_ops.ops.sort_by(|l, r| l.id.cmp(&r.id));
} else {
target.push(s_ops);
}
}
target
}
#[gpui::test]
async fn test_repository_pending_ops_staging(
executor: gpui::BackgroundExecutor,
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(executor);
fs.insert_tree(
path!("/root"),
json!({
"my-repo": {
".git": {},
"a.txt": "a",
}
}),
)
.await;
fs.set_status_for_repo(
path!("/root/my-repo/.git").as_ref(),
&[("a.txt", FileStatus::Untracked)],
);
let project = Project::test(fs.clone(), [path!("/root/my-repo").as_ref()], cx).await;
let pending_ops_all = Arc::new(Mutex::new(SumTree::default()));
project.update(cx, |project, cx| {
let pending_ops_all = pending_ops_all.clone();
cx.subscribe(project.git_store(), move |_, _, e, _| {
if let GitStoreEvent::RepositoryUpdated(
_,
RepositoryEvent::PendingOpsChanged { pending_ops },
_,
) = e
{
let merged = merge_pending_ops_snapshots(
pending_ops.items(()),
pending_ops_all.lock().items(()),
);
*pending_ops_all.lock() = SumTree::from_iter(merged.into_iter(), ());
}
})
.detach();
});
project
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
let repo = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
// Ensure we have no pending ops for any of the untracked files
repo.read_with(cx, |repo, _cx| {
assert!(repo.pending_ops_by_path.is_empty());
});
let mut id = 1u16;
let mut assert_stage = async |path: RepoPath, stage| {
let git_status = if stage {
pending_op::GitStatus::Staged
} else {
pending_op::GitStatus::Unstaged
};
repo.update(cx, |repo, cx| {
let task = if stage {
repo.stage_entries(vec![path.clone()], cx)
} else {
repo.unstage_entries(vec![path.clone()], cx)
};
let ops = repo.pending_ops_for_path(&path).unwrap();
assert_eq!(
ops.ops.last(),
Some(&pending_op::PendingOp {
id: id.into(),
git_status,
job_status: pending_op::JobStatus::Running
})
);
task
})
.await
.unwrap();
repo.read_with(cx, |repo, _cx| {
let ops = repo.pending_ops_for_path(&path).unwrap();
assert_eq!(
ops.ops.last(),
Some(&pending_op::PendingOp {
id: id.into(),
git_status,
job_status: pending_op::JobStatus::Finished
})
);
});
id += 1;
};
assert_stage(repo_path("a.txt"), true).await;
assert_stage(repo_path("a.txt"), false).await;
assert_stage(repo_path("a.txt"), true).await;
assert_stage(repo_path("a.txt"), false).await;
assert_stage(repo_path("a.txt"), true).await;
cx.run_until_parked();
assert_eq!(
pending_ops_all
.lock()
.get(&worktree::PathKey(repo_path("a.txt").0), ())
.unwrap()
.ops,
vec![
pending_op::PendingOp {
id: 1u16.into(),
git_status: pending_op::GitStatus::Staged,
job_status: pending_op::JobStatus::Finished
},
pending_op::PendingOp {
id: 2u16.into(),
git_status: pending_op::GitStatus::Unstaged,
job_status: pending_op::JobStatus::Finished
},
pending_op::PendingOp {
id: 3u16.into(),
git_status: pending_op::GitStatus::Staged,
job_status: pending_op::JobStatus::Finished
},
pending_op::PendingOp {
id: 4u16.into(),
git_status: pending_op::GitStatus::Unstaged,
job_status: pending_op::JobStatus::Finished
},
pending_op::PendingOp {
id: 5u16.into(),
git_status: pending_op::GitStatus::Staged,
job_status: pending_op::JobStatus::Finished
}
],
);
repo.update(cx, |repo, _cx| {
let git_statuses = repo.cached_status().collect::<Vec<_>>();
assert_eq!(
git_statuses,
[StatusEntry {
repo_path: repo_path("a.txt"),
status: TrackedStatus {
index_status: StatusCode::Added,
worktree_status: StatusCode::Unmodified
}
.into(),
}]
);
});
}
#[gpui::test]
async fn test_repository_pending_ops_long_running_staging(
executor: gpui::BackgroundExecutor,
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(executor);
fs.insert_tree(
path!("/root"),
json!({
"my-repo": {
".git": {},
"a.txt": "a",
}
}),
)
.await;
fs.set_status_for_repo(
path!("/root/my-repo/.git").as_ref(),
&[("a.txt", FileStatus::Untracked)],
);
let project = Project::test(fs.clone(), [path!("/root/my-repo").as_ref()], cx).await;
let pending_ops_all = Arc::new(Mutex::new(SumTree::default()));
project.update(cx, |project, cx| {
let pending_ops_all = pending_ops_all.clone();
cx.subscribe(project.git_store(), move |_, _, e, _| {
if let GitStoreEvent::RepositoryUpdated(
_,
RepositoryEvent::PendingOpsChanged { pending_ops },
_,
) = e
{
let merged = merge_pending_ops_snapshots(
pending_ops.items(()),
pending_ops_all.lock().items(()),
);
*pending_ops_all.lock() = SumTree::from_iter(merged.into_iter(), ());
}
})
.detach();
});
project
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
let repo = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
repo.update(cx, |repo, cx| {
repo.stage_entries(vec![repo_path("a.txt")], cx)
})
.detach();
repo.update(cx, |repo, cx| {
repo.stage_entries(vec![repo_path("a.txt")], cx)
})
.unwrap()
.with_timeout(Duration::from_secs(1), &cx.executor())
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
pending_ops_all
.lock()
.get(&worktree::PathKey(repo_path("a.txt").0), ())
.unwrap()
.ops,
vec![
pending_op::PendingOp {
id: 1u16.into(),
git_status: pending_op::GitStatus::Staged,
job_status: pending_op::JobStatus::Skipped
},
pending_op::PendingOp {
id: 2u16.into(),
git_status: pending_op::GitStatus::Staged,
job_status: pending_op::JobStatus::Finished
}
],
);
repo.update(cx, |repo, _cx| {
let git_statuses = repo.cached_status().collect::<Vec<_>>();
assert_eq!(
git_statuses,
[StatusEntry {
repo_path: repo_path("a.txt"),
status: TrackedStatus {
index_status: StatusCode::Added,
worktree_status: StatusCode::Unmodified
}
.into(),
}]
);
});
}
#[gpui::test]
async fn test_repository_pending_ops_stage_all(
executor: gpui::BackgroundExecutor,
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(executor);
fs.insert_tree(
path!("/root"),
json!({
"my-repo": {
".git": {},
"a.txt": "a",
"b.txt": "b"
}
}),
)
.await;
fs.set_status_for_repo(
path!("/root/my-repo/.git").as_ref(),
&[
("a.txt", FileStatus::Untracked),
("b.txt", FileStatus::Untracked),
],
);
let project = Project::test(fs.clone(), [path!("/root/my-repo").as_ref()], cx).await;
let pending_ops_all = Arc::new(Mutex::new(SumTree::default()));
project.update(cx, |project, cx| {
let pending_ops_all = pending_ops_all.clone();
cx.subscribe(project.git_store(), move |_, _, e, _| {
if let GitStoreEvent::RepositoryUpdated(
_,
RepositoryEvent::PendingOpsChanged { pending_ops },
_,
) = e
{
let merged = merge_pending_ops_snapshots(
pending_ops.items(()),
pending_ops_all.lock().items(()),
);
*pending_ops_all.lock() = SumTree::from_iter(merged.into_iter(), ());
}
})
.detach();
});
project
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
let repo = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
repo.update(cx, |repo, cx| {
repo.stage_entries(vec![repo_path("a.txt")], cx)
})
.await
.unwrap();
repo.update(cx, |repo, cx| repo.stage_all(cx))
.await
.unwrap();
repo.update(cx, |repo, cx| repo.unstage_all(cx))
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
pending_ops_all
.lock()
.get(&worktree::PathKey(repo_path("a.txt").0), ())
.unwrap()
.ops,
vec![
pending_op::PendingOp {
id: 1u16.into(),
git_status: pending_op::GitStatus::Staged,
job_status: pending_op::JobStatus::Finished
},
pending_op::PendingOp {
id: 2u16.into(),
git_status: pending_op::GitStatus::Unstaged,
job_status: pending_op::JobStatus::Finished
},
],
);
assert_eq!(
pending_ops_all
.lock()
.get(&worktree::PathKey(repo_path("b.txt").0), ())
.unwrap()
.ops,
vec![
pending_op::PendingOp {
id: 1u16.into(),
git_status: pending_op::GitStatus::Staged,
job_status: pending_op::JobStatus::Finished
},
pending_op::PendingOp {
id: 2u16.into(),
git_status: pending_op::GitStatus::Unstaged,
job_status: pending_op::JobStatus::Finished
},
],
);
repo.update(cx, |repo, _cx| {
let git_statuses = repo.cached_status().collect::<Vec<_>>();
assert_eq!(
git_statuses,
[
StatusEntry {
repo_path: repo_path("a.txt"),
status: FileStatus::Untracked,
},
StatusEntry {
repo_path: repo_path("b.txt"),
status: FileStatus::Untracked,
},
]
);
});
}
#[gpui::test]
async fn test_repository_subfolder_git_status(
executor: gpui::BackgroundExecutor,