Move repository state RPC handlers to the GitStore (#27391)
This is another in the series of PRs to make the GitStore own all repository state and enable better concurrency control for git repository scans. After this PR, the `RepositoryEntry`s stored in worktree snapshots are used only as a staging ground for local GitStores to pull from after git-related events; non-local worktrees don't store them at all, although this is not reflected in the types. GitTraversal and other places that need information about repositories get it from the GitStore. The GitStore also takes over handling of the new UpdateRepository and RemoveRepository messages. However, repositories are still discovered and scanned on a per-worktree basis, and we're still identifying them by the (worktree-specific) project entry ID of their working directory. - [x] Remove WorkDirectory from RepositoryEntry - [x] Remove worktree IDs from repository-related RPC messages - [x] Handle UpdateRepository and RemoveRepository RPCs from the GitStore Release Notes: - N/A --------- Co-authored-by: Max <max@zed.dev> Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
parent
1e8b50f471
commit
6924720b35
22 changed files with 1387 additions and 1399 deletions
|
@ -41,7 +41,7 @@ use postage::{
|
|||
watch,
|
||||
};
|
||||
use rpc::{
|
||||
proto::{self, split_worktree_related_message, FromProto, ToProto, WorktreeRelatedMessage},
|
||||
proto::{self, split_worktree_update, FromProto, ToProto},
|
||||
AnyProtoClient,
|
||||
};
|
||||
pub use settings::WorktreeId;
|
||||
|
@ -138,12 +138,12 @@ struct ScanRequest {
|
|||
|
||||
pub struct RemoteWorktree {
|
||||
snapshot: Snapshot,
|
||||
background_snapshot: Arc<Mutex<(Snapshot, Vec<WorktreeRelatedMessage>)>>,
|
||||
background_snapshot: Arc<Mutex<(Snapshot, Vec<proto::UpdateWorktree>)>>,
|
||||
project_id: u64,
|
||||
client: AnyProtoClient,
|
||||
file_scan_inclusions: PathMatcher,
|
||||
updates_tx: Option<UnboundedSender<WorktreeRelatedMessage>>,
|
||||
update_observer: Option<mpsc::UnboundedSender<WorktreeRelatedMessage>>,
|
||||
updates_tx: Option<UnboundedSender<proto::UpdateWorktree>>,
|
||||
update_observer: Option<mpsc::UnboundedSender<proto::UpdateWorktree>>,
|
||||
snapshot_subscriptions: VecDeque<(usize, oneshot::Sender<()>)>,
|
||||
replica_id: ReplicaId,
|
||||
visible: bool,
|
||||
|
@ -196,28 +196,25 @@ pub struct RepositoryEntry {
|
|||
/// - my_sub_folder_1/project_root/changed_file_1
|
||||
/// - my_sub_folder_2/changed_file_2
|
||||
pub statuses_by_path: SumTree<StatusEntry>,
|
||||
work_directory_id: ProjectEntryId,
|
||||
pub work_directory: WorkDirectory,
|
||||
work_directory_abs_path: PathBuf,
|
||||
pub(crate) current_branch: Option<Branch>,
|
||||
pub work_directory_id: ProjectEntryId,
|
||||
pub work_directory_abs_path: PathBuf,
|
||||
pub worktree_scan_id: usize,
|
||||
pub current_branch: Option<Branch>,
|
||||
pub current_merge_conflicts: TreeSet<RepoPath>,
|
||||
}
|
||||
|
||||
impl RepositoryEntry {
|
||||
pub fn relativize(&self, path: &Path) -> Result<RepoPath> {
|
||||
self.work_directory.relativize(path)
|
||||
pub fn relativize_abs_path(&self, abs_path: &Path) -> Option<RepoPath> {
|
||||
Some(
|
||||
abs_path
|
||||
.strip_prefix(&self.work_directory_abs_path)
|
||||
.ok()?
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn try_unrelativize(&self, path: &RepoPath) -> Option<Arc<Path>> {
|
||||
self.work_directory.try_unrelativize(path)
|
||||
}
|
||||
|
||||
pub fn unrelativize(&self, path: &RepoPath) -> Arc<Path> {
|
||||
self.work_directory.unrelativize(path)
|
||||
}
|
||||
|
||||
pub fn directory_contains(&self, path: impl AsRef<Path>) -> bool {
|
||||
self.work_directory.directory_contains(path)
|
||||
pub fn directory_contains_abs_path(&self, abs_path: impl AsRef<Path>) -> bool {
|
||||
abs_path.as_ref().starts_with(&self.work_directory_abs_path)
|
||||
}
|
||||
|
||||
pub fn branch(&self) -> Option<&Branch> {
|
||||
|
@ -246,11 +243,7 @@ impl RepositoryEntry {
|
|||
.cloned()
|
||||
}
|
||||
|
||||
pub fn initial_update(
|
||||
&self,
|
||||
project_id: u64,
|
||||
worktree_scan_id: usize,
|
||||
) -> proto::UpdateRepository {
|
||||
pub fn initial_update(&self, project_id: u64) -> proto::UpdateRepository {
|
||||
proto::UpdateRepository {
|
||||
branch_summary: self.current_branch.as_ref().map(branch_to_proto),
|
||||
updated_statuses: self
|
||||
|
@ -274,16 +267,11 @@ impl RepositoryEntry {
|
|||
entry_ids: vec![self.work_directory_id().to_proto()],
|
||||
// This is also semantically wrong, and should be replaced once we separate git repo updates
|
||||
// from worktree scans.
|
||||
scan_id: worktree_scan_id as u64,
|
||||
scan_id: self.worktree_scan_id as u64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_update(
|
||||
&self,
|
||||
old: &Self,
|
||||
project_id: u64,
|
||||
scan_id: usize,
|
||||
) -> proto::UpdateRepository {
|
||||
pub fn build_update(&self, old: &Self, project_id: u64) -> proto::UpdateRepository {
|
||||
let mut updated_statuses: Vec<proto::StatusEntry> = Vec::new();
|
||||
let mut removed_statuses: Vec<String> = Vec::new();
|
||||
|
||||
|
@ -338,7 +326,7 @@ impl RepositoryEntry {
|
|||
id: self.work_directory_id.to_proto(),
|
||||
abs_path: self.work_directory_abs_path.as_path().to_proto(),
|
||||
entry_ids: vec![self.work_directory_id.to_proto()],
|
||||
scan_id: scan_id as u64,
|
||||
scan_id: self.worktree_scan_id as u64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -428,28 +416,21 @@ impl WorkDirectory {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn canonicalize(&self) -> Self {
|
||||
match self {
|
||||
WorkDirectory::InProject { relative_path } => WorkDirectory::InProject {
|
||||
relative_path: relative_path.clone(),
|
||||
},
|
||||
WorkDirectory::AboveProject {
|
||||
absolute_path,
|
||||
location_in_repo,
|
||||
} => WorkDirectory::AboveProject {
|
||||
absolute_path: absolute_path.canonicalize().unwrap().into(),
|
||||
location_in_repo: location_in_repo.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_above_project(&self) -> bool {
|
||||
match self {
|
||||
WorkDirectory::InProject { .. } => false,
|
||||
WorkDirectory::AboveProject { .. } => true,
|
||||
}
|
||||
}
|
||||
//#[cfg(test)]
|
||||
//fn canonicalize(&self) -> Self {
|
||||
// match self {
|
||||
// WorkDirectory::InProject { relative_path } => WorkDirectory::InProject {
|
||||
// relative_path: relative_path.clone(),
|
||||
// },
|
||||
// WorkDirectory::AboveProject {
|
||||
// absolute_path,
|
||||
// location_in_repo,
|
||||
// } => WorkDirectory::AboveProject {
|
||||
// absolute_path: absolute_path.canonicalize().unwrap().into(),
|
||||
// location_in_repo: location_in_repo.clone(),
|
||||
// },
|
||||
// }
|
||||
//}
|
||||
|
||||
fn path_key(&self) -> PathKey {
|
||||
match self {
|
||||
|
@ -699,8 +680,7 @@ enum ScanState {
|
|||
}
|
||||
|
||||
struct UpdateObservationState {
|
||||
snapshots_tx:
|
||||
mpsc::UnboundedSender<(LocalSnapshot, UpdatedEntriesSet, UpdatedGitRepositoriesSet)>,
|
||||
snapshots_tx: mpsc::UnboundedSender<(LocalSnapshot, UpdatedEntriesSet)>,
|
||||
resume_updates: watch::Sender<()>,
|
||||
_maintain_remote_snapshot: Task<Option<()>>,
|
||||
}
|
||||
|
@ -824,10 +804,10 @@ impl Worktree {
|
|||
|
||||
let background_snapshot = Arc::new(Mutex::new((
|
||||
snapshot.clone(),
|
||||
Vec::<WorktreeRelatedMessage>::new(),
|
||||
Vec::<proto::UpdateWorktree>::new(),
|
||||
)));
|
||||
let (background_updates_tx, mut background_updates_rx) =
|
||||
mpsc::unbounded::<WorktreeRelatedMessage>();
|
||||
mpsc::unbounded::<proto::UpdateWorktree>();
|
||||
let (mut snapshot_updated_tx, mut snapshot_updated_rx) = watch::channel();
|
||||
|
||||
let worktree_id = snapshot.id();
|
||||
|
@ -872,25 +852,14 @@ impl Worktree {
|
|||
cx.spawn(async move |this, cx| {
|
||||
while (snapshot_updated_rx.recv().await).is_some() {
|
||||
this.update(cx, |this, cx| {
|
||||
let mut git_repos_changed = false;
|
||||
let mut entries_changed = false;
|
||||
let this = this.as_remote_mut().unwrap();
|
||||
{
|
||||
let mut lock = this.background_snapshot.lock();
|
||||
this.snapshot = lock.0.clone();
|
||||
for update in lock.1.drain(..) {
|
||||
entries_changed |= match &update {
|
||||
WorktreeRelatedMessage::UpdateWorktree(update_worktree) => {
|
||||
!update_worktree.updated_entries.is_empty()
|
||||
|| !update_worktree.removed_entries.is_empty()
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
git_repos_changed |= matches!(
|
||||
update,
|
||||
WorktreeRelatedMessage::UpdateRepository(_)
|
||||
| WorktreeRelatedMessage::RemoveRepository(_)
|
||||
);
|
||||
entries_changed |= !update.updated_entries.is_empty()
|
||||
|| !update.removed_entries.is_empty();
|
||||
if let Some(tx) = &this.update_observer {
|
||||
tx.unbounded_send(update).ok();
|
||||
}
|
||||
|
@ -900,9 +869,6 @@ impl Worktree {
|
|||
if entries_changed {
|
||||
cx.emit(Event::UpdatedEntries(Arc::default()));
|
||||
}
|
||||
if git_repos_changed {
|
||||
cx.emit(Event::UpdatedGitRepositories(Arc::default()));
|
||||
}
|
||||
cx.notify();
|
||||
while let Some((scan_id, _)) = this.snapshot_subscriptions.front() {
|
||||
if this.observed_snapshot(*scan_id) {
|
||||
|
@ -1027,7 +993,7 @@ impl Worktree {
|
|||
|
||||
pub fn observe_updates<F, Fut>(&mut self, project_id: u64, cx: &Context<Worktree>, callback: F)
|
||||
where
|
||||
F: 'static + Send + Fn(WorktreeRelatedMessage) -> Fut,
|
||||
F: 'static + Send + Fn(proto::UpdateWorktree) -> Fut,
|
||||
Fut: 'static + Send + Future<Output = bool>,
|
||||
{
|
||||
match self {
|
||||
|
@ -1070,7 +1036,7 @@ impl Worktree {
|
|||
let path = Arc::from(path);
|
||||
let snapshot = this.snapshot();
|
||||
cx.spawn(async move |cx| {
|
||||
if let Some(repo) = snapshot.repository_for_path(&path) {
|
||||
if let Some(repo) = snapshot.local_repo_containing_path(&path) {
|
||||
if let Some(repo_path) = repo.relativize(&path).log_err() {
|
||||
if let Some(git_repo) =
|
||||
snapshot.git_repositories.get(&repo.work_directory_id)
|
||||
|
@ -1097,7 +1063,7 @@ impl Worktree {
|
|||
let path = Arc::from(path);
|
||||
let snapshot = this.snapshot();
|
||||
cx.spawn(async move |cx| {
|
||||
if let Some(repo) = snapshot.repository_for_path(&path) {
|
||||
if let Some(repo) = snapshot.local_repo_containing_path(&path) {
|
||||
if let Some(repo_path) = repo.relativize(&path).log_err() {
|
||||
if let Some(git_repo) =
|
||||
snapshot.git_repositories.get(&repo.work_directory_id)
|
||||
|
@ -1611,11 +1577,7 @@ impl LocalWorktree {
|
|||
if let Some(share) = self.update_observer.as_mut() {
|
||||
share
|
||||
.snapshots_tx
|
||||
.unbounded_send((
|
||||
self.snapshot.clone(),
|
||||
entry_changes.clone(),
|
||||
repo_changes.clone(),
|
||||
))
|
||||
.unbounded_send((self.snapshot.clone(), entry_changes.clone()))
|
||||
.ok();
|
||||
}
|
||||
|
||||
|
@ -1656,10 +1618,8 @@ impl LocalWorktree {
|
|||
|| new_repo.status_scan_id != old_repo.status_scan_id
|
||||
{
|
||||
if let Some(entry) = new_snapshot.entry_for_id(new_entry_id) {
|
||||
let old_repo = old_snapshot
|
||||
.repositories
|
||||
.get(&PathKey(entry.path.clone()), &())
|
||||
.cloned();
|
||||
let old_repo =
|
||||
old_snapshot.repository_for_id(old_entry_id).cloned();
|
||||
changes.push((
|
||||
entry.clone(),
|
||||
GitRepositoryChange {
|
||||
|
@ -1673,10 +1633,8 @@ impl LocalWorktree {
|
|||
}
|
||||
Ordering::Greater => {
|
||||
if let Some(entry) = old_snapshot.entry_for_id(old_entry_id) {
|
||||
let old_repo = old_snapshot
|
||||
.repositories
|
||||
.get(&PathKey(entry.path.clone()), &())
|
||||
.cloned();
|
||||
let old_repo =
|
||||
old_snapshot.repository_for_id(old_entry_id).cloned();
|
||||
changes.push((
|
||||
entry.clone(),
|
||||
GitRepositoryChange {
|
||||
|
@ -1701,10 +1659,7 @@ impl LocalWorktree {
|
|||
}
|
||||
(None, Some((entry_id, _))) => {
|
||||
if let Some(entry) = old_snapshot.entry_for_id(entry_id) {
|
||||
let old_repo = old_snapshot
|
||||
.repositories
|
||||
.get(&PathKey(entry.path.clone()), &())
|
||||
.cloned();
|
||||
let old_repo = old_snapshot.repository_for_id(entry_id).cloned();
|
||||
changes.push((
|
||||
entry.clone(),
|
||||
GitRepositoryChange {
|
||||
|
@ -2320,7 +2275,7 @@ impl LocalWorktree {
|
|||
|
||||
fn observe_updates<F, Fut>(&mut self, project_id: u64, cx: &Context<Worktree>, callback: F)
|
||||
where
|
||||
F: 'static + Send + Fn(WorktreeRelatedMessage) -> Fut,
|
||||
F: 'static + Send + Fn(proto::UpdateWorktree) -> Fut,
|
||||
Fut: 'static + Send + Future<Output = bool>,
|
||||
{
|
||||
if let Some(observer) = self.update_observer.as_mut() {
|
||||
|
@ -2330,26 +2285,23 @@ impl LocalWorktree {
|
|||
|
||||
let (resume_updates_tx, mut resume_updates_rx) = watch::channel::<()>();
|
||||
let (snapshots_tx, mut snapshots_rx) =
|
||||
mpsc::unbounded::<(LocalSnapshot, UpdatedEntriesSet, UpdatedGitRepositoriesSet)>();
|
||||
mpsc::unbounded::<(LocalSnapshot, UpdatedEntriesSet)>();
|
||||
snapshots_tx
|
||||
.unbounded_send((self.snapshot(), Arc::default(), Arc::default()))
|
||||
.unbounded_send((self.snapshot(), Arc::default()))
|
||||
.ok();
|
||||
|
||||
let worktree_id = cx.entity_id().as_u64();
|
||||
let _maintain_remote_snapshot = cx.background_spawn(async move {
|
||||
let mut is_first = true;
|
||||
while let Some((snapshot, entry_changes, repo_changes)) = snapshots_rx.next().await {
|
||||
let updates = if is_first {
|
||||
while let Some((snapshot, entry_changes)) = snapshots_rx.next().await {
|
||||
let update = if is_first {
|
||||
is_first = false;
|
||||
snapshot.build_initial_update(project_id, worktree_id)
|
||||
} else {
|
||||
snapshot.build_update(project_id, worktree_id, entry_changes, repo_changes)
|
||||
snapshot.build_update(project_id, worktree_id, entry_changes)
|
||||
};
|
||||
|
||||
for update in updates
|
||||
.into_iter()
|
||||
.flat_map(proto::split_worktree_related_message)
|
||||
{
|
||||
for update in proto::split_worktree_update(update) {
|
||||
let _ = resume_updates_rx.try_recv();
|
||||
loop {
|
||||
let result = callback(update.clone());
|
||||
|
@ -2412,7 +2364,7 @@ impl RemoteWorktree {
|
|||
self.disconnected = true;
|
||||
}
|
||||
|
||||
pub fn update_from_remote(&self, update: WorktreeRelatedMessage) {
|
||||
pub fn update_from_remote(&self, update: proto::UpdateWorktree) {
|
||||
if let Some(updates_tx) = &self.updates_tx {
|
||||
updates_tx
|
||||
.unbounded_send(update)
|
||||
|
@ -2422,41 +2374,29 @@ impl RemoteWorktree {
|
|||
|
||||
fn observe_updates<F, Fut>(&mut self, project_id: u64, cx: &Context<Worktree>, callback: F)
|
||||
where
|
||||
F: 'static + Send + Fn(WorktreeRelatedMessage) -> Fut,
|
||||
F: 'static + Send + Fn(proto::UpdateWorktree) -> Fut,
|
||||
Fut: 'static + Send + Future<Output = bool>,
|
||||
{
|
||||
let (tx, mut rx) = mpsc::unbounded();
|
||||
let initial_updates = self
|
||||
let initial_update = self
|
||||
.snapshot
|
||||
.build_initial_update(project_id, self.id().to_proto());
|
||||
self.update_observer = Some(tx);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let mut updates = initial_updates;
|
||||
let mut update = initial_update;
|
||||
'outer: loop {
|
||||
for mut update in updates {
|
||||
// SSH projects use a special project ID of 0, and we need to
|
||||
// remap it to the correct one here.
|
||||
match &mut update {
|
||||
WorktreeRelatedMessage::UpdateWorktree(update_worktree) => {
|
||||
update_worktree.project_id = project_id;
|
||||
}
|
||||
WorktreeRelatedMessage::UpdateRepository(update_repository) => {
|
||||
update_repository.project_id = project_id;
|
||||
}
|
||||
WorktreeRelatedMessage::RemoveRepository(remove_repository) => {
|
||||
remove_repository.project_id = project_id;
|
||||
}
|
||||
};
|
||||
// SSH projects use a special project ID of 0, and we need to
|
||||
// remap it to the correct one here.
|
||||
update.project_id = project_id;
|
||||
|
||||
for chunk in split_worktree_related_message(update) {
|
||||
if !callback(chunk).await {
|
||||
break 'outer;
|
||||
}
|
||||
for chunk in split_worktree_update(update) {
|
||||
if !callback(chunk).await {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(next_update) = rx.next().await {
|
||||
updates = vec![next_update];
|
||||
update = next_update;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
@ -2616,11 +2556,7 @@ impl Snapshot {
|
|||
self.abs_path.as_path()
|
||||
}
|
||||
|
||||
fn build_initial_update(
|
||||
&self,
|
||||
project_id: u64,
|
||||
worktree_id: u64,
|
||||
) -> Vec<WorktreeRelatedMessage> {
|
||||
fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree {
|
||||
let mut updated_entries = self
|
||||
.entries_by_path
|
||||
.iter()
|
||||
|
@ -2628,7 +2564,7 @@ impl Snapshot {
|
|||
.collect::<Vec<_>>();
|
||||
updated_entries.sort_unstable_by_key(|e| e.id);
|
||||
|
||||
[proto::UpdateWorktree {
|
||||
proto::UpdateWorktree {
|
||||
project_id,
|
||||
worktree_id,
|
||||
abs_path: self.abs_path().to_proto(),
|
||||
|
@ -2641,14 +2577,15 @@ impl Snapshot {
|
|||
updated_repositories: Vec::new(),
|
||||
removed_repositories: Vec::new(),
|
||||
}
|
||||
.into()]
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.repositories
|
||||
.iter()
|
||||
.map(|repository| repository.initial_update(project_id, self.scan_id).into()),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn work_directory_abs_path(&self, work_directory: &WorkDirectory) -> Result<PathBuf> {
|
||||
match work_directory {
|
||||
WorkDirectory::InProject { relative_path } => self.absolutize(relative_path),
|
||||
WorkDirectory::AboveProject { absolute_path, .. } => {
|
||||
Ok(absolute_path.as_ref().to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn absolutize(&self, path: &Path) -> Result<PathBuf> {
|
||||
|
@ -2712,15 +2649,24 @@ impl Snapshot {
|
|||
Some(removed_entry.path)
|
||||
}
|
||||
|
||||
//#[cfg(any(test, feature = "test-support"))]
|
||||
//pub fn status_for_file(&self, path: impl AsRef<Path>) -> Option<FileStatus> {
|
||||
// let path = path.as_ref();
|
||||
// self.repository_for_path(path).and_then(|repo| {
|
||||
// let repo_path = repo.relativize(path).unwrap();
|
||||
// repo.statuses_by_path
|
||||
// .get(&PathKey(repo_path.0), &())
|
||||
// .map(|entry| entry.status)
|
||||
// })
|
||||
//}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn status_for_file(&self, path: impl AsRef<Path>) -> Option<FileStatus> {
|
||||
let path = path.as_ref();
|
||||
self.repository_for_path(path).and_then(|repo| {
|
||||
let repo_path = repo.relativize(path).unwrap();
|
||||
repo.statuses_by_path
|
||||
.get(&PathKey(repo_path.0), &())
|
||||
.map(|entry| entry.status)
|
||||
})
|
||||
pub fn status_for_file_abs_path(&self, abs_path: impl AsRef<Path>) -> Option<FileStatus> {
|
||||
let abs_path = abs_path.as_ref();
|
||||
let repo = self.repository_containing_abs_path(abs_path)?;
|
||||
let repo_path = repo.relativize_abs_path(abs_path)?;
|
||||
let status = repo.statuses_by_path.get(&PathKey(repo_path.0), &())?;
|
||||
Some(status.status)
|
||||
}
|
||||
|
||||
fn update_abs_path(&mut self, abs_path: SanitizedPath, root_name: String) {
|
||||
|
@ -2731,95 +2677,7 @@ impl Snapshot {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_update_repository(
|
||||
&mut self,
|
||||
update: proto::UpdateRepository,
|
||||
) -> Result<()> {
|
||||
// NOTE: this is practically but not semantically correct. For now we're using the
|
||||
// ID field to store the work directory ID, but eventually it will be a different
|
||||
// kind of ID.
|
||||
let work_directory_id = ProjectEntryId::from_proto(update.id);
|
||||
|
||||
if let Some(work_dir_entry) = self.entry_for_id(work_directory_id) {
|
||||
let conflicted_paths = TreeSet::from_ordered_entries(
|
||||
update
|
||||
.current_merge_conflicts
|
||||
.into_iter()
|
||||
.map(|path| RepoPath(Path::new(&path).into())),
|
||||
);
|
||||
|
||||
if self
|
||||
.repositories
|
||||
.contains(&PathKey(work_dir_entry.path.clone()), &())
|
||||
{
|
||||
let edits = update
|
||||
.removed_statuses
|
||||
.into_iter()
|
||||
.map(|path| Edit::Remove(PathKey(FromProto::from_proto(path))))
|
||||
.chain(
|
||||
update
|
||||
.updated_statuses
|
||||
.into_iter()
|
||||
.filter_map(|updated_status| {
|
||||
Some(Edit::Insert(updated_status.try_into().log_err()?))
|
||||
}),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.repositories
|
||||
.update(&PathKey(work_dir_entry.path.clone()), &(), |repo| {
|
||||
repo.current_branch = update.branch_summary.as_ref().map(proto_to_branch);
|
||||
repo.statuses_by_path.edit(edits, &());
|
||||
repo.current_merge_conflicts = conflicted_paths
|
||||
});
|
||||
} else {
|
||||
let statuses = SumTree::from_iter(
|
||||
update
|
||||
.updated_statuses
|
||||
.into_iter()
|
||||
.filter_map(|updated_status| updated_status.try_into().log_err()),
|
||||
&(),
|
||||
);
|
||||
|
||||
self.repositories.insert_or_replace(
|
||||
RepositoryEntry {
|
||||
work_directory_id,
|
||||
// When syncing repository entries from a peer, we don't need
|
||||
// the location_in_repo field, since git operations don't happen locally
|
||||
// anyway.
|
||||
work_directory: WorkDirectory::InProject {
|
||||
relative_path: work_dir_entry.path.clone(),
|
||||
},
|
||||
current_branch: update.branch_summary.as_ref().map(proto_to_branch),
|
||||
statuses_by_path: statuses,
|
||||
current_merge_conflicts: conflicted_paths,
|
||||
work_directory_abs_path: update.abs_path.into(),
|
||||
},
|
||||
&(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::error!("no work directory entry for repository {:?}", update.id)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_remove_repository(
|
||||
&mut self,
|
||||
update: proto::RemoveRepository,
|
||||
) -> Result<()> {
|
||||
// NOTE: this is practically but not semantically correct. For now we're using the
|
||||
// ID field to store the work directory ID, but eventually it will be a different
|
||||
// kind of ID.
|
||||
let work_directory_id = ProjectEntryId::from_proto(update.id);
|
||||
self.repositories.retain(&(), |entry: &RepositoryEntry| {
|
||||
entry.work_directory_id != work_directory_id
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_update_worktree(
|
||||
pub(crate) fn apply_remote_update(
|
||||
&mut self,
|
||||
update: proto::UpdateWorktree,
|
||||
always_included_paths: &PathMatcher,
|
||||
|
@ -2875,24 +2733,6 @@ impl Snapshot {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply_remote_update(
|
||||
&mut self,
|
||||
update: WorktreeRelatedMessage,
|
||||
always_included_paths: &PathMatcher,
|
||||
) -> Result<()> {
|
||||
match update {
|
||||
WorktreeRelatedMessage::UpdateWorktree(update) => {
|
||||
self.apply_update_worktree(update, always_included_paths)
|
||||
}
|
||||
WorktreeRelatedMessage::UpdateRepository(update) => {
|
||||
self.apply_update_repository(update)
|
||||
}
|
||||
WorktreeRelatedMessage::RemoveRepository(update) => {
|
||||
self.apply_remove_repository(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries_by_path.summary().count
|
||||
}
|
||||
|
@ -2972,48 +2812,18 @@ impl Snapshot {
|
|||
&self.repositories
|
||||
}
|
||||
|
||||
/// Get the repository whose work directory corresponds to the given path.
|
||||
fn repository(&self, work_directory: PathKey) -> Option<RepositoryEntry> {
|
||||
self.repositories.get(&work_directory, &()).cloned()
|
||||
}
|
||||
|
||||
/// Get the repository whose work directory contains the given path.
|
||||
#[track_caller]
|
||||
pub fn repository_for_path(&self, path: &Path) -> Option<&RepositoryEntry> {
|
||||
fn repository_containing_abs_path(&self, abs_path: &Path) -> Option<&RepositoryEntry> {
|
||||
self.repositories
|
||||
.iter()
|
||||
.filter(|repo| repo.directory_contains(path))
|
||||
.filter(|repo| repo.directory_contains_abs_path(abs_path))
|
||||
.last()
|
||||
}
|
||||
|
||||
/// Given an ordered iterator of entries, returns an iterator of those entries,
|
||||
/// along with their containing git repository.
|
||||
#[cfg(test)]
|
||||
#[track_caller]
|
||||
fn entries_with_repositories<'a>(
|
||||
&'a self,
|
||||
entries: impl 'a + Iterator<Item = &'a Entry>,
|
||||
) -> impl 'a + Iterator<Item = (&'a Entry, Option<&'a RepositoryEntry>)> {
|
||||
let mut containing_repos = Vec::<&RepositoryEntry>::new();
|
||||
let mut repositories = self.repositories.iter().peekable();
|
||||
entries.map(move |entry| {
|
||||
while let Some(repository) = containing_repos.last() {
|
||||
if repository.directory_contains(&entry.path) {
|
||||
break;
|
||||
} else {
|
||||
containing_repos.pop();
|
||||
}
|
||||
}
|
||||
while let Some(repository) = repositories.peek() {
|
||||
if repository.directory_contains(&entry.path) {
|
||||
containing_repos.push(repositories.next().unwrap());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let repo = containing_repos.last().copied();
|
||||
(entry, repo)
|
||||
})
|
||||
fn repository_for_id(&self, id: ProjectEntryId) -> Option<&RepositoryEntry> {
|
||||
self.repositories
|
||||
.iter()
|
||||
.find(|repo| repo.work_directory_id == id)
|
||||
}
|
||||
|
||||
pub fn paths(&self) -> impl Iterator<Item = &Arc<Path>> {
|
||||
|
@ -3098,10 +2908,18 @@ impl Snapshot {
|
|||
}
|
||||
|
||||
impl LocalSnapshot {
|
||||
pub fn local_repo_for_path(&self, path: &Path) -> Option<&LocalRepositoryEntry> {
|
||||
let repository_entry = self.repository_for_path(path)?;
|
||||
let work_directory_id = repository_entry.work_directory_id();
|
||||
self.git_repositories.get(&work_directory_id)
|
||||
pub fn local_repo_for_work_directory_path(&self, path: &Path) -> Option<&LocalRepositoryEntry> {
|
||||
self.git_repositories
|
||||
.iter()
|
||||
.map(|(_, entry)| entry)
|
||||
.find(|entry| entry.work_directory.path_key() == PathKey(path.into()))
|
||||
}
|
||||
|
||||
pub fn local_repo_containing_path(&self, path: &Path) -> Option<&LocalRepositoryEntry> {
|
||||
self.git_repositories
|
||||
.values()
|
||||
.filter(|local_repo| path.starts_with(&local_repo.path_key().0))
|
||||
.max_by_key(|local_repo| local_repo.path_key())
|
||||
}
|
||||
|
||||
fn build_update(
|
||||
|
@ -3109,11 +2927,9 @@ impl LocalSnapshot {
|
|||
project_id: u64,
|
||||
worktree_id: u64,
|
||||
entry_changes: UpdatedEntriesSet,
|
||||
repo_changes: UpdatedGitRepositoriesSet,
|
||||
) -> Vec<WorktreeRelatedMessage> {
|
||||
) -> proto::UpdateWorktree {
|
||||
let mut updated_entries = Vec::new();
|
||||
let mut removed_entries = Vec::new();
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for (_, entry_id, path_change) in entry_changes.iter() {
|
||||
if let PathChange::Removed = path_change {
|
||||
|
@ -3123,55 +2939,25 @@ impl LocalSnapshot {
|
|||
}
|
||||
}
|
||||
|
||||
for (entry, change) in repo_changes.iter() {
|
||||
let new_repo = self.repositories.get(&PathKey(entry.path.clone()), &());
|
||||
match (&change.old_repository, new_repo) {
|
||||
(Some(old_repo), Some(new_repo)) => {
|
||||
updates.push(
|
||||
new_repo
|
||||
.build_update(old_repo, project_id, self.scan_id)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
(None, Some(new_repo)) => {
|
||||
updates.push(new_repo.initial_update(project_id, self.scan_id).into());
|
||||
}
|
||||
(Some(old_repo), None) => {
|
||||
updates.push(
|
||||
proto::RemoveRepository {
|
||||
project_id,
|
||||
id: old_repo.work_directory_id.to_proto(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
removed_entries.sort_unstable();
|
||||
updated_entries.sort_unstable_by_key(|e| e.id);
|
||||
|
||||
// TODO - optimize, knowing that removed_entries are sorted.
|
||||
removed_entries.retain(|id| updated_entries.binary_search_by_key(id, |e| e.id).is_err());
|
||||
|
||||
updates.push(
|
||||
proto::UpdateWorktree {
|
||||
project_id,
|
||||
worktree_id,
|
||||
abs_path: self.abs_path().to_proto(),
|
||||
root_name: self.root_name().to_string(),
|
||||
updated_entries,
|
||||
removed_entries,
|
||||
scan_id: self.scan_id as u64,
|
||||
is_last_update: self.completed_scan_id == self.scan_id,
|
||||
// Sent in separate messages.
|
||||
updated_repositories: Vec::new(),
|
||||
removed_repositories: Vec::new(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
updates
|
||||
proto::UpdateWorktree {
|
||||
project_id,
|
||||
worktree_id,
|
||||
abs_path: self.abs_path().to_proto(),
|
||||
root_name: self.root_name().to_string(),
|
||||
updated_entries,
|
||||
removed_entries,
|
||||
scan_id: self.scan_id as u64,
|
||||
is_last_update: self.completed_scan_id == self.scan_id,
|
||||
// Sent in separate messages.
|
||||
updated_repositories: Vec::new(),
|
||||
removed_repositories: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry {
|
||||
|
@ -3351,7 +3137,7 @@ impl LocalSnapshot {
|
|||
let work_dir_paths = self
|
||||
.repositories
|
||||
.iter()
|
||||
.map(|repo| repo.work_directory.path_key())
|
||||
.map(|repo| repo.work_directory_abs_path.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
assert_eq!(dotgit_paths.len(), work_dir_paths.len());
|
||||
assert_eq!(self.repositories.iter().count(), work_dir_paths.len());
|
||||
|
@ -3560,14 +3346,9 @@ impl BackgroundScannerState {
|
|||
.git_repositories
|
||||
.retain(|id, _| removed_ids.binary_search(id).is_err());
|
||||
self.snapshot.repositories.retain(&(), |repository| {
|
||||
let retain = !repository.work_directory.path_key().0.starts_with(path);
|
||||
if !retain {
|
||||
log::info!(
|
||||
"dropping repository entry for {:?}",
|
||||
repository.work_directory
|
||||
);
|
||||
}
|
||||
retain
|
||||
removed_ids
|
||||
.binary_search(&repository.work_directory_id)
|
||||
.is_err()
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -3622,9 +3403,13 @@ impl BackgroundScannerState {
|
|||
fs: &dyn Fs,
|
||||
watcher: &dyn Watcher,
|
||||
) -> Option<LocalRepositoryEntry> {
|
||||
// TODO canonicalize here
|
||||
log::info!("insert git repository for {dot_git_path:?}");
|
||||
let work_dir_entry = self.snapshot.entry_for_path(work_directory.path_key().0)?;
|
||||
let work_directory_abs_path = self.snapshot.absolutize(&work_dir_entry.path).log_err()?;
|
||||
let work_directory_abs_path = self
|
||||
.snapshot
|
||||
.work_directory_abs_path(&work_directory)
|
||||
.log_err()?;
|
||||
|
||||
if self
|
||||
.snapshot
|
||||
|
@ -3676,18 +3461,18 @@ impl BackgroundScannerState {
|
|||
self.snapshot.repositories.insert_or_replace(
|
||||
RepositoryEntry {
|
||||
work_directory_id,
|
||||
work_directory: work_directory.clone(),
|
||||
work_directory_abs_path,
|
||||
current_branch: None,
|
||||
statuses_by_path: Default::default(),
|
||||
current_merge_conflicts: Default::default(),
|
||||
worktree_scan_id: 0,
|
||||
},
|
||||
&(),
|
||||
);
|
||||
|
||||
let local_repository = LocalRepositoryEntry {
|
||||
work_directory_id,
|
||||
work_directory: work_directory.clone(),
|
||||
work_directory,
|
||||
git_dir_scan_id: 0,
|
||||
status_scan_id: 0,
|
||||
repo_ptr: repository.clone(),
|
||||
|
@ -4120,22 +3905,53 @@ impl<'a, S: Summary> sum_tree::Dimension<'a, PathSummary<S>> for PathProgress<'a
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AbsPathSummary {
|
||||
max_path: Arc<Path>,
|
||||
}
|
||||
|
||||
impl Summary for AbsPathSummary {
|
||||
type Context = ();
|
||||
|
||||
fn zero(_: &Self::Context) -> Self {
|
||||
Self {
|
||||
max_path: Path::new("").into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, rhs: &Self, _: &Self::Context) {
|
||||
self.max_path = rhs.max_path.clone();
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Item for RepositoryEntry {
|
||||
type Summary = PathSummary<Unit>;
|
||||
type Summary = AbsPathSummary;
|
||||
|
||||
fn summary(&self, _: &<Self::Summary as Summary>::Context) -> Self::Summary {
|
||||
PathSummary {
|
||||
max_path: self.work_directory.path_key().0,
|
||||
item_summary: Unit,
|
||||
AbsPathSummary {
|
||||
max_path: self.work_directory_abs_path.as_path().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct AbsPathKey(pub Arc<Path>);
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, AbsPathSummary> for AbsPathKey {
|
||||
fn zero(_: &()) -> Self {
|
||||
Self(Path::new("").into())
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, summary: &'a AbsPathSummary, _: &()) {
|
||||
self.0 = summary.max_path.clone();
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::KeyedItem for RepositoryEntry {
|
||||
type Key = PathKey;
|
||||
type Key = AbsPathKey;
|
||||
|
||||
fn key(&self) -> Self::Key {
|
||||
self.work_directory.path_key()
|
||||
AbsPathKey(self.work_directory_abs_path.as_path().into())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4375,7 +4191,7 @@ impl<'a> sum_tree::Dimension<'a, PathEntrySummary> for ProjectEntryId {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||
pub struct PathKey(Arc<Path>);
|
||||
pub struct PathKey(pub Arc<Path>);
|
||||
|
||||
impl Default for PathKey {
|
||||
fn default() -> Self {
|
||||
|
@ -5191,11 +5007,11 @@ impl BackgroundScanner {
|
|||
|
||||
// Group all relative paths by their git repository.
|
||||
let mut paths_by_git_repo = HashMap::default();
|
||||
for relative_path in relative_paths.iter() {
|
||||
for (relative_path, abs_path) in relative_paths.iter().zip(&abs_paths) {
|
||||
let repository_data = state
|
||||
.snapshot
|
||||
.local_repo_for_path(relative_path)
|
||||
.zip(state.snapshot.repository_for_path(relative_path));
|
||||
.local_repo_containing_path(relative_path)
|
||||
.zip(state.snapshot.repository_containing_abs_path(abs_path));
|
||||
if let Some((local_repo, entry)) = repository_data {
|
||||
if let Ok(repo_path) = local_repo.relativize(relative_path) {
|
||||
paths_by_git_repo
|
||||
|
@ -5210,7 +5026,7 @@ impl BackgroundScanner {
|
|||
}
|
||||
}
|
||||
|
||||
for (work_directory, mut paths) in paths_by_git_repo {
|
||||
for (_work_directory, mut paths) in paths_by_git_repo {
|
||||
if let Ok(status) = paths.repo.status(&paths.repo_paths) {
|
||||
let mut changed_path_statuses = Vec::new();
|
||||
let statuses = paths.entry.statuses_by_path.clone();
|
||||
|
@ -5239,7 +5055,7 @@ impl BackgroundScanner {
|
|||
|
||||
if !changed_path_statuses.is_empty() {
|
||||
let work_directory_id = state.snapshot.repositories.update(
|
||||
&work_directory.path_key(),
|
||||
&AbsPathKey(paths.entry.work_directory_abs_path.as_path().into()),
|
||||
&(),
|
||||
move |repository_entry| {
|
||||
repository_entry
|
||||
|
@ -5324,14 +5140,13 @@ impl BackgroundScanner {
|
|||
.components()
|
||||
.any(|component| component.as_os_str() == *DOT_GIT)
|
||||
{
|
||||
if let Some(repository) = snapshot.repository(PathKey(path.clone())) {
|
||||
if let Some(local_repo) = snapshot.local_repo_for_work_directory_path(path) {
|
||||
let id = local_repo.work_directory_id;
|
||||
log::debug!("remove repo path: {:?}", path);
|
||||
snapshot.git_repositories.remove(&id);
|
||||
snapshot
|
||||
.git_repositories
|
||||
.remove(&repository.work_directory_id);
|
||||
snapshot
|
||||
.snapshot
|
||||
.repositories
|
||||
.remove(&repository.work_directory.path_key(), &());
|
||||
.retain(&(), |repo_entry| repo_entry.work_directory_id != id);
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
|
@ -5540,6 +5355,17 @@ impl BackgroundScanner {
|
|||
entry.status_scan_id = scan_id;
|
||||
},
|
||||
);
|
||||
if let Some(repo_entry) = state
|
||||
.snapshot
|
||||
.repository_for_id(local_repository.work_directory_id)
|
||||
{
|
||||
let abs_path_key =
|
||||
AbsPathKey(repo_entry.work_directory_abs_path.as_path().into());
|
||||
state
|
||||
.snapshot
|
||||
.repositories
|
||||
.update(&abs_path_key, &(), |repo| repo.worktree_scan_id = scan_id);
|
||||
}
|
||||
|
||||
local_repository
|
||||
}
|
||||
|
@ -5674,8 +5500,11 @@ async fn update_branches(
|
|||
let branches = repository.repo().branches().await?;
|
||||
let snapshot = state.lock().snapshot.snapshot.clone();
|
||||
let mut repository = snapshot
|
||||
.repository(repository.work_directory.path_key())
|
||||
.context("Missing repository")?;
|
||||
.repositories
|
||||
.iter()
|
||||
.find(|repo_entry| repo_entry.work_directory_id == repository.work_directory_id)
|
||||
.context("missing repository")?
|
||||
.clone();
|
||||
repository.current_branch = branches.into_iter().find(|branch| branch.is_head);
|
||||
|
||||
let mut state = state.lock();
|
||||
|
@ -5717,9 +5546,10 @@ async fn do_git_status_update(
|
|||
let snapshot = job_state.lock().snapshot.snapshot.clone();
|
||||
|
||||
let Some(mut repository) = snapshot
|
||||
.repository(local_repository.work_directory.path_key())
|
||||
.context("Tried to update git statuses for a repository that isn't in the snapshot")
|
||||
.repository_for_id(local_repository.work_directory_id)
|
||||
.context("tried to update git statuses for a repository that isn't in the snapshot")
|
||||
.log_err()
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
@ -5731,7 +5561,7 @@ async fn do_git_status_update(
|
|||
|
||||
let mut new_entries_by_path = SumTree::new(&());
|
||||
for (repo_path, status) in statuses.entries.iter() {
|
||||
let project_path = repository.work_directory.try_unrelativize(repo_path);
|
||||
let project_path = local_repository.work_directory.try_unrelativize(repo_path);
|
||||
|
||||
new_entries_by_path.insert_or_replace(
|
||||
StatusEntry {
|
||||
|
@ -5749,6 +5579,7 @@ async fn do_git_status_update(
|
|||
}
|
||||
}
|
||||
|
||||
log::trace!("statuses: {:#?}", new_entries_by_path);
|
||||
repository.statuses_by_path = new_entries_by_path;
|
||||
let mut state = job_state.lock();
|
||||
state
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, WorkDirectory,
|
||||
Worktree, WorktreeModelHandle,
|
||||
worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, StatusEntry,
|
||||
WorkDirectory, Worktree, WorktreeModelHandle,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use fs::{FakeFs, Fs, RealFs, RemoveOptions};
|
||||
|
@ -15,7 +15,7 @@ use parking_lot::Mutex;
|
|||
use postage::stream::Stream;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rand::prelude::*;
|
||||
use rpc::proto::WorktreeRelatedMessage;
|
||||
|
||||
use serde_json::json;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use std::{
|
||||
|
@ -1665,12 +1665,7 @@ async fn test_random_worktree_operations_during_initial_scan(
|
|||
for (i, snapshot) in snapshots.into_iter().enumerate().rev() {
|
||||
let mut updated_snapshot = snapshot.clone();
|
||||
for update in updates.lock().iter() {
|
||||
let scan_id = match update {
|
||||
WorktreeRelatedMessage::UpdateWorktree(update) => update.scan_id,
|
||||
WorktreeRelatedMessage::UpdateRepository(update) => update.scan_id,
|
||||
WorktreeRelatedMessage::RemoveRepository(_) => u64::MAX,
|
||||
};
|
||||
if scan_id >= updated_snapshot.scan_id() as u64 {
|
||||
if update.scan_id >= updated_snapshot.scan_id() as u64 {
|
||||
updated_snapshot
|
||||
.apply_remote_update(update.clone(), &settings.file_scan_inclusions)
|
||||
.unwrap();
|
||||
|
@ -1807,12 +1802,7 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng)
|
|||
|
||||
for (i, mut prev_snapshot) in snapshots.into_iter().enumerate().rev() {
|
||||
for update in updates.lock().iter() {
|
||||
let scan_id = match update {
|
||||
WorktreeRelatedMessage::UpdateWorktree(update) => update.scan_id,
|
||||
WorktreeRelatedMessage::UpdateRepository(update) => update.scan_id,
|
||||
WorktreeRelatedMessage::RemoveRepository(_) => u64::MAX,
|
||||
};
|
||||
if scan_id >= prev_snapshot.scan_id() as u64 {
|
||||
if update.scan_id >= prev_snapshot.scan_id() as u64 {
|
||||
prev_snapshot
|
||||
.apply_remote_update(update.clone(), &settings.file_scan_inclusions)
|
||||
.unwrap();
|
||||
|
@ -2157,15 +2147,15 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
|
|||
let tree = tree.read(cx);
|
||||
let repo = tree.repositories.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
repo.work_directory,
|
||||
WorkDirectory::in_project("projects/project1")
|
||||
repo.work_directory_abs_path,
|
||||
root_path.join("projects/project1")
|
||||
);
|
||||
assert_eq!(
|
||||
tree.status_for_file(Path::new("projects/project1/a")),
|
||||
repo.status_for_path(&"a".into()).map(|entry| entry.status),
|
||||
Some(StatusCode::Modified.worktree()),
|
||||
);
|
||||
assert_eq!(
|
||||
tree.status_for_file(Path::new("projects/project1/b")),
|
||||
repo.status_for_path(&"b".into()).map(|entry| entry.status),
|
||||
Some(FileStatus::Untracked),
|
||||
);
|
||||
});
|
||||
|
@ -2181,201 +2171,20 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
|
|||
let tree = tree.read(cx);
|
||||
let repo = tree.repositories.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
repo.work_directory,
|
||||
WorkDirectory::in_project("projects/project2")
|
||||
repo.work_directory_abs_path,
|
||||
root_path.join("projects/project2")
|
||||
);
|
||||
assert_eq!(
|
||||
tree.status_for_file(Path::new("projects/project2/a")),
|
||||
Some(StatusCode::Modified.worktree()),
|
||||
repo.status_for_path(&"a".into()).unwrap().status,
|
||||
StatusCode::Modified.worktree(),
|
||||
);
|
||||
assert_eq!(
|
||||
tree.status_for_file(Path::new("projects/project2/b")),
|
||||
Some(FileStatus::Untracked),
|
||||
repo.status_for_path(&"b".into()).unwrap().status,
|
||||
FileStatus::Untracked,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_home_dir_as_git_repository(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.executor().allow_parking();
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
"home": {
|
||||
".git": {},
|
||||
"project": {
|
||||
"a.txt": "A"
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
fs.set_home_dir(Path::new(path!("/root/home")).to_owned());
|
||||
|
||||
let tree = Worktree::local(
|
||||
Path::new(path!("/root/home/project")),
|
||||
true,
|
||||
fs.clone(),
|
||||
Default::default(),
|
||||
&mut cx.to_async(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
||||
.await;
|
||||
tree.flush_fs_events(cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let tree = tree.as_local().unwrap();
|
||||
|
||||
let repo = tree.repository_for_path(path!("a.txt").as_ref());
|
||||
assert!(repo.is_none());
|
||||
});
|
||||
|
||||
let home_tree = Worktree::local(
|
||||
Path::new(path!("/root/home")),
|
||||
true,
|
||||
fs.clone(),
|
||||
Default::default(),
|
||||
&mut cx.to_async(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.read(|cx| home_tree.read(cx).as_local().unwrap().scan_complete())
|
||||
.await;
|
||||
home_tree.flush_fs_events(cx).await;
|
||||
|
||||
home_tree.read_with(cx, |home_tree, _cx| {
|
||||
let home_tree = home_tree.as_local().unwrap();
|
||||
|
||||
let repo = home_tree.repository_for_path(path!("project/a.txt").as_ref());
|
||||
assert_eq!(
|
||||
repo.map(|repo| &repo.work_directory),
|
||||
Some(&WorkDirectory::InProject {
|
||||
relative_path: Path::new("").into()
|
||||
})
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_git_repository_for_path(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.executor().allow_parking();
|
||||
let root = TempTree::new(json!({
|
||||
"c.txt": "",
|
||||
"dir1": {
|
||||
".git": {},
|
||||
"deps": {
|
||||
"dep1": {
|
||||
".git": {},
|
||||
"src": {
|
||||
"a.txt": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"src": {
|
||||
"b.txt": ""
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
let tree = Worktree::local(
|
||||
root.path(),
|
||||
true,
|
||||
Arc::new(RealFs::default()),
|
||||
Default::default(),
|
||||
&mut cx.to_async(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
||||
.await;
|
||||
tree.flush_fs_events(cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let tree = tree.as_local().unwrap();
|
||||
|
||||
assert!(tree.repository_for_path("c.txt".as_ref()).is_none());
|
||||
|
||||
let repo = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap();
|
||||
assert_eq!(repo.work_directory, WorkDirectory::in_project("dir1"));
|
||||
|
||||
let repo = tree
|
||||
.repository_for_path("dir1/deps/dep1/src/a.txt".as_ref())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repo.work_directory,
|
||||
WorkDirectory::in_project("dir1/deps/dep1")
|
||||
);
|
||||
|
||||
let entries = tree.files(false, 0);
|
||||
|
||||
let paths_with_repos = tree
|
||||
.entries_with_repositories(entries)
|
||||
.map(|(entry, repo)| {
|
||||
(
|
||||
entry.path.as_ref(),
|
||||
repo.map(|repo| repo.work_directory.clone()),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
paths_with_repos,
|
||||
&[
|
||||
(Path::new("c.txt"), None),
|
||||
(
|
||||
Path::new("dir1/deps/dep1/src/a.txt"),
|
||||
Some(WorkDirectory::in_project("dir1/deps/dep1"))
|
||||
),
|
||||
(
|
||||
Path::new("dir1/src/b.txt"),
|
||||
Some(WorkDirectory::in_project("dir1"))
|
||||
),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
let repo_update_events = Arc::new(Mutex::new(vec![]));
|
||||
tree.update(cx, |_, cx| {
|
||||
let repo_update_events = repo_update_events.clone();
|
||||
cx.subscribe(&tree, move |_, _, event, _| {
|
||||
if let Event::UpdatedGitRepositories(update) = event {
|
||||
repo_update_events.lock().push(update.clone());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
|
||||
tree.flush_fs_events(cx).await;
|
||||
|
||||
assert_eq!(
|
||||
repo_update_events.lock()[0]
|
||||
.iter()
|
||||
.map(|(entry, _)| entry.path.clone())
|
||||
.collect::<Vec<Arc<Path>>>(),
|
||||
vec![Path::new("dir1").into()]
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
|
||||
tree.flush_fs_events(cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let tree = tree.as_local().unwrap();
|
||||
|
||||
assert!(tree
|
||||
.repository_for_path("dir1/src/b.txt".as_ref())
|
||||
.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: This test always fails on Windows, because on Windows, unlike on Unix,
|
||||
// you can't rename a directory which some program has already open. This is a
|
||||
// limitation of the Windows. See:
|
||||
|
@ -2411,7 +2220,6 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
const F_TXT: &str = "f.txt";
|
||||
const DOTGITIGNORE: &str = ".gitignore";
|
||||
const BUILD_FILE: &str = "target/build_file";
|
||||
let project_path = Path::new("project");
|
||||
|
||||
// Set up git repository before creating the worktree.
|
||||
let work_dir = root.path().join("project");
|
||||
|
@ -2431,6 +2239,7 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let root_path = root.path();
|
||||
|
||||
tree.flush_fs_events(cx).await;
|
||||
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
||||
|
@ -2443,17 +2252,17 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
assert_eq!(snapshot.repositories.iter().count(), 1);
|
||||
let repo_entry = snapshot.repositories.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
repo_entry.work_directory,
|
||||
WorkDirectory::in_project("project")
|
||||
repo_entry.work_directory_abs_path,
|
||||
root_path.join("project")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(project_path.join(B_TXT)),
|
||||
Some(FileStatus::Untracked),
|
||||
repo_entry.status_for_path(&B_TXT.into()).unwrap().status,
|
||||
FileStatus::Untracked,
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(project_path.join(F_TXT)),
|
||||
Some(FileStatus::Untracked),
|
||||
repo_entry.status_for_path(&F_TXT.into()).unwrap().status,
|
||||
FileStatus::Untracked,
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -2465,9 +2274,11 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
// The worktree detects that the file's git status has changed.
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let snapshot = tree.snapshot();
|
||||
assert_eq!(snapshot.repositories.iter().count(), 1);
|
||||
let repo_entry = snapshot.repositories.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(project_path.join(A_TXT)),
|
||||
Some(StatusCode::Modified.worktree()),
|
||||
repo_entry.status_for_path(&A_TXT.into()).unwrap().status,
|
||||
StatusCode::Modified.worktree(),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -2481,12 +2292,14 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
// The worktree detects that the files' git status have changed.
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let snapshot = tree.snapshot();
|
||||
assert_eq!(snapshot.repositories.iter().count(), 1);
|
||||
let repo_entry = snapshot.repositories.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(project_path.join(F_TXT)),
|
||||
Some(FileStatus::Untracked),
|
||||
repo_entry.status_for_path(&F_TXT.into()).unwrap().status,
|
||||
FileStatus::Untracked,
|
||||
);
|
||||
assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None);
|
||||
assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
|
||||
assert_eq!(repo_entry.status_for_path(&B_TXT.into()), None);
|
||||
assert_eq!(repo_entry.status_for_path(&A_TXT.into()), None);
|
||||
});
|
||||
|
||||
// Modify files in the working copy and perform git operations on other files.
|
||||
|
@ -2501,15 +2314,17 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
// Check that more complex repo changes are tracked
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let snapshot = tree.snapshot();
|
||||
assert_eq!(snapshot.repositories.iter().count(), 1);
|
||||
let repo_entry = snapshot.repositories.iter().next().unwrap();
|
||||
|
||||
assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None);
|
||||
assert_eq!(repo_entry.status_for_path(&A_TXT.into()), None);
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(project_path.join(B_TXT)),
|
||||
Some(FileStatus::Untracked),
|
||||
repo_entry.status_for_path(&B_TXT.into()).unwrap().status,
|
||||
FileStatus::Untracked,
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(project_path.join(E_TXT)),
|
||||
Some(StatusCode::Modified.worktree()),
|
||||
repo_entry.status_for_path(&E_TXT.into()).unwrap().status,
|
||||
StatusCode::Modified.worktree(),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -2542,9 +2357,14 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let snapshot = tree.snapshot();
|
||||
assert_eq!(snapshot.repositories.iter().count(), 1);
|
||||
let repo_entry = snapshot.repositories.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(project_path.join(renamed_dir_name).join(RENAMED_FILE)),
|
||||
Some(FileStatus::Untracked),
|
||||
repo_entry
|
||||
.status_for_path(&Path::new(renamed_dir_name).join(RENAMED_FILE).into())
|
||||
.unwrap()
|
||||
.status,
|
||||
FileStatus::Untracked,
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -2561,14 +2381,15 @@ async fn test_file_status(cx: &mut TestAppContext) {
|
|||
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let snapshot = tree.snapshot();
|
||||
assert_eq!(snapshot.repositories.iter().count(), 1);
|
||||
let repo_entry = snapshot.repositories.iter().next().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
snapshot.status_for_file(
|
||||
project_path
|
||||
.join(Path::new(renamed_dir_name))
|
||||
.join(RENAMED_FILE)
|
||||
),
|
||||
Some(FileStatus::Untracked),
|
||||
repo_entry
|
||||
.status_for_path(&Path::new(renamed_dir_name).join(RENAMED_FILE).into())
|
||||
.unwrap()
|
||||
.status,
|
||||
FileStatus::Untracked,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -2619,17 +2440,26 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
|
|||
let repo = snapshot.repositories.iter().next().unwrap();
|
||||
let entries = repo.status().collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
|
||||
assert_eq!(entries[0].status, StatusCode::Modified.worktree());
|
||||
assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
|
||||
assert_eq!(entries[1].status, FileStatus::Untracked);
|
||||
assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt"));
|
||||
assert_eq!(entries[2].status, StatusCode::Deleted.worktree());
|
||||
assert_eq!(
|
||||
entries,
|
||||
[
|
||||
StatusEntry {
|
||||
repo_path: "a.txt".into(),
|
||||
status: StatusCode::Modified.worktree(),
|
||||
},
|
||||
StatusEntry {
|
||||
repo_path: "b.txt".into(),
|
||||
status: FileStatus::Untracked,
|
||||
},
|
||||
StatusEntry {
|
||||
repo_path: "d.txt".into(),
|
||||
status: StatusCode::Deleted.worktree(),
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
std::fs::write(work_dir.join("c.txt"), "some changes").unwrap();
|
||||
eprintln!("File c.txt has been modified");
|
||||
|
||||
tree.flush_fs_events(cx).await;
|
||||
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
||||
|
@ -2641,16 +2471,27 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
|
|||
let repository = snapshot.repositories.iter().next().unwrap();
|
||||
let entries = repository.status().collect::<Vec<_>>();
|
||||
|
||||
std::assert_eq!(entries.len(), 4, "entries: {entries:?}");
|
||||
assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
|
||||
assert_eq!(entries[0].status, StatusCode::Modified.worktree());
|
||||
assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt"));
|
||||
assert_eq!(entries[1].status, FileStatus::Untracked);
|
||||
// Status updated
|
||||
assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt"));
|
||||
assert_eq!(entries[2].status, StatusCode::Modified.worktree());
|
||||
assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt"));
|
||||
assert_eq!(entries[3].status, StatusCode::Deleted.worktree());
|
||||
assert_eq!(
|
||||
entries,
|
||||
[
|
||||
StatusEntry {
|
||||
repo_path: "a.txt".into(),
|
||||
status: StatusCode::Modified.worktree(),
|
||||
},
|
||||
StatusEntry {
|
||||
repo_path: "b.txt".into(),
|
||||
status: FileStatus::Untracked,
|
||||
},
|
||||
StatusEntry {
|
||||
repo_path: "c.txt".into(),
|
||||
status: StatusCode::Modified.worktree(),
|
||||
},
|
||||
StatusEntry {
|
||||
repo_path: "d.txt".into(),
|
||||
status: StatusCode::Deleted.worktree(),
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
git_add("a.txt", &repo);
|
||||
|
@ -2677,13 +2518,12 @@ async fn test_git_repository_status(cx: &mut TestAppContext) {
|
|||
// Deleting an untracked entry, b.txt, should leave no status
|
||||
// a.txt was tracked, and so should have a status
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"Entries length was incorrect\n{:#?}",
|
||||
&entries
|
||||
entries,
|
||||
[StatusEntry {
|
||||
repo_path: "a.txt".into(),
|
||||
status: StatusCode::Deleted.worktree(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
|
||||
assert_eq!(entries[0].status, StatusCode::Deleted.worktree());
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2729,17 +2569,18 @@ async fn test_git_status_postprocessing(cx: &mut TestAppContext) {
|
|||
let entries = repo.status().collect::<Vec<_>>();
|
||||
|
||||
// `sub` doesn't appear in our computed statuses.
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt"));
|
||||
// a.txt appears with a combined `DA` status.
|
||||
assert_eq!(
|
||||
entries[0].status,
|
||||
TrackedStatus {
|
||||
index_status: StatusCode::Deleted,
|
||||
worktree_status: StatusCode::Added
|
||||
}
|
||||
.into()
|
||||
);
|
||||
entries,
|
||||
[StatusEntry {
|
||||
repo_path: "a.txt".into(),
|
||||
status: TrackedStatus {
|
||||
index_status: StatusCode::Deleted,
|
||||
worktree_status: StatusCode::Added
|
||||
}
|
||||
.into(),
|
||||
}]
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2797,19 +2638,14 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
|
|||
assert_eq!(snapshot.repositories.iter().count(), 1);
|
||||
let repo = snapshot.repositories.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
repo.work_directory.canonicalize(),
|
||||
WorkDirectory::AboveProject {
|
||||
absolute_path: Arc::from(root.path().join("my-repo").canonicalize().unwrap()),
|
||||
location_in_repo: Arc::from(Path::new(util::separator!(
|
||||
"sub-folder-1/sub-folder-2"
|
||||
)))
|
||||
}
|
||||
repo.work_directory_abs_path.canonicalize().unwrap(),
|
||||
root.path().join("my-repo").canonicalize().unwrap()
|
||||
);
|
||||
|
||||
assert_eq!(snapshot.status_for_file("c.txt"), None);
|
||||
assert_eq!(repo.status_for_path(&C_TXT.into()), None);
|
||||
assert_eq!(
|
||||
snapshot.status_for_file("d/e.txt"),
|
||||
Some(FileStatus::Untracked)
|
||||
repo.status_for_path(&E_TXT.into()).unwrap().status,
|
||||
FileStatus::Untracked
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -2823,11 +2659,14 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
|
|||
|
||||
tree.read_with(cx, |tree, _cx| {
|
||||
let snapshot = tree.snapshot();
|
||||
let repos = snapshot.repositories().iter().cloned().collect::<Vec<_>>();
|
||||
assert_eq!(repos.len(), 1);
|
||||
let repo_entry = repos.into_iter().next().unwrap();
|
||||
|
||||
assert!(snapshot.repositories.iter().next().is_some());
|
||||
|
||||
assert_eq!(snapshot.status_for_file("c.txt"), None);
|
||||
assert_eq!(snapshot.status_for_file("d/e.txt"), None);
|
||||
assert_eq!(repo_entry.status_for_path(&C_TXT.into()), None);
|
||||
assert_eq!(repo_entry.status_for_path(&E_TXT.into()), None);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -3140,7 +2979,12 @@ fn assert_entry_git_state(
|
|||
is_ignored: bool,
|
||||
) {
|
||||
let entry = tree.entry_for_path(path).expect("entry {path} not found");
|
||||
let status = tree.status_for_file(Path::new(path));
|
||||
let repos = tree.repositories().iter().cloned().collect::<Vec<_>>();
|
||||
assert_eq!(repos.len(), 1);
|
||||
let repo_entry = repos.into_iter().next().unwrap();
|
||||
let status = repo_entry
|
||||
.status_for_path(&path.into())
|
||||
.map(|entry| entry.status);
|
||||
let expected = index_status.map(|index_status| {
|
||||
TrackedStatus {
|
||||
index_status,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue