From b0a7defd0990c315c27f51f82dbf13a736279eba Mon Sep 17 00:00:00 2001 From: ozzy <109994179+ddoemonn@users.noreply.github.com> Date: Mon, 17 Nov 2025 22:25:51 +0300 Subject: [PATCH] Fix track file renames in git panel (#42352) Closes #30549 Release Notes: - Fixed: Git renames now properly show as renamed files in the git panel instead of appearing as deleted + untracked files Screenshot 2025-11-10 at 17 39 44 Screenshot 2025-11-10 at 17 39 55 --- crates/collab/src/db/queries/projects.rs | 1 + crates/collab/src/db/queries/rooms.rs | 1 + crates/fs/src/fake_git_repo.rs | 1 + crates/git/src/repository.rs | 2 +- crates/git/src/status.rs | 95 +++++++++++++++++++----- crates/git_ui/src/git_panel.rs | 64 ++++++++++------ crates/git_ui/src/git_ui.rs | 5 ++ crates/project/src/git_store.rs | 24 ++++++ crates/proto/proto/git.proto | 1 + 9 files changed, 150 insertions(+), 44 deletions(-) diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 51a0ef83323ec70675283d2fdec7ca1ad791b12d..c8651216434d404f7ab4a88fbb5fbb5f7d0aa3ee 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -1005,6 +1005,7 @@ impl Database { is_last_update: true, merge_message: db_repository_entry.merge_message, stash_entries: Vec::new(), + renamed_paths: Default::default(), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index f020b99b5f1030cfe9391498512258e6db249bac..151e4c442bd7d0a25053e35b94d9e2ad9817a6a3 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -796,6 +796,7 @@ impl Database { is_last_update: true, merge_message: db_repository.merge_message, stash_entries: Vec::new(), + renamed_paths: Default::default(), }); } } diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 97cd13d185817453c369356bdc60cbc1517bf1e1..de7c0561ebc9918a2686402fb9b62608566c7d9c 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -359,6 +359,7 @@ impl GitRepository for FakeGitRepository { entries.sort_by(|a, b| a.0.cmp(&b.0)); anyhow::Ok(GitStatus { entries: entries.into(), + renamed_paths: HashMap::default(), }) }); Task::ready(match result { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 2c9189962492daa75dba86e9e2ebd247ad85254e..2eb37038cde2f4d0c4dc4903fdc06f86ab543827 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -2045,7 +2045,7 @@ fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { OsString::from("status"), OsString::from("--porcelain=v1"), OsString::from("--untracked-files=all"), - OsString::from("--no-renames"), + OsString::from("--find-renames"), OsString::from("-z"), ]; args.extend( diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 2cf7cc7c1810620f1cf1aaea831fb337810c83d8..9b76fe75dd284c08c0f2e9b20116bc51dc4bc56c 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -203,6 +203,14 @@ impl FileStatus { matches!(self, FileStatus::Untracked) } + pub fn is_renamed(self) -> bool { + let FileStatus::Tracked(tracked) = self else { + return false; + }; + tracked.index_status == StatusCode::Renamed + || tracked.worktree_status == StatusCode::Renamed + } + pub fn summary(self) -> GitSummary { match self { FileStatus::Ignored => GitSummary::UNCHANGED, @@ -430,34 +438,79 @@ impl std::ops::Sub for GitSummary { #[derive(Clone, Debug)] pub struct GitStatus { pub entries: Arc<[(RepoPath, FileStatus)]>, + pub renamed_paths: HashMap, } impl FromStr for GitStatus { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - let mut entries = s - .split('\0') - .filter_map(|entry| { - let sep = entry.get(2..3)?; - if sep != " " { - return None; + let mut parts = s.split('\0').peekable(); + let mut entries = Vec::new(); + let mut renamed_paths = HashMap::default(); + + while let Some(entry) = parts.next() { + if entry.is_empty() { + continue; + } + + if !matches!(entry.get(2..3), Some(" ")) { + continue; + } + + let path_or_old_path = &entry[3..]; + + if path_or_old_path.ends_with('/') { + continue; + } + + let status = match entry.as_bytes()[0..2].try_into() { + Ok(bytes) => match FileStatus::from_bytes(bytes).log_err() { + Some(s) => s, + None => continue, + }, + Err(_) => continue, + }; + + let is_rename = matches!( + status, + FileStatus::Tracked(TrackedStatus { + index_status: StatusCode::Renamed | StatusCode::Copied, + .. + }) | FileStatus::Tracked(TrackedStatus { + worktree_status: StatusCode::Renamed | StatusCode::Copied, + .. + }) + ); + + let (old_path_str, new_path_str) = if is_rename { + let new_path = match parts.next() { + Some(new_path) if !new_path.is_empty() => new_path, + _ => continue, }; - let path = &entry[3..]; - // The git status output includes untracked directories as well as untracked files. - // We do our own processing to compute the "summary" status of each directory, - // so just skip any directories in the output, since they'll otherwise interfere - // with our handling of nested repositories. - if path.ends_with('/') { - return None; + (path_or_old_path, new_path) + } else { + (path_or_old_path, path_or_old_path) + }; + + if new_path_str.ends_with('/') { + continue; + } + + let new_path = match RelPath::unix(new_path_str).log_err() { + Some(p) => RepoPath::from_rel_path(p), + None => continue, + }; + + if is_rename { + if let Some(old_path_rel) = RelPath::unix(old_path_str).log_err() { + let old_path_repo = RepoPath::from_rel_path(old_path_rel); + renamed_paths.insert(new_path.clone(), old_path_repo); } - let status = entry.as_bytes()[0..2].try_into().unwrap(); - let status = FileStatus::from_bytes(status).log_err()?; - // git-status outputs `/`-delimited repo paths, even on Windows. - let path = RepoPath::from_rel_path(RelPath::unix(path).log_err()?); - Some((path, status)) - }) - .collect::>(); + } + + entries.push((new_path, status)); + } entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); // When a file exists in HEAD, is deleted in the index, and exists again in the working copy, // git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy) @@ -481,6 +534,7 @@ impl FromStr for GitStatus { }); Ok(Self { entries: entries.into(), + renamed_paths, }) } } @@ -489,6 +543,7 @@ impl Default for GitStatus { fn default() -> Self { Self { entries: Arc::new([]), + renamed_paths: HashMap::default(), } } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e2a4a26b320284fed727a7f7e60acf807c39abf0..0691ba78560e38f5d3a297d033bd41459dff78c4 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3957,6 +3957,20 @@ impl GitPanel { let path_style = self.project.read(cx).path_style(cx); let display_name = entry.display_name(path_style); + let active_repo = self + .project + .read(cx) + .active_repository(cx) + .expect("active repository must be set"); + let repo = active_repo.read(cx); + let repo_snapshot = repo.snapshot(); + + let old_path = if entry.status.is_renamed() { + repo_snapshot.renamed_paths.get(&entry.repo_path) + } else { + None + }; + let selected = self.selected_entry == Some(ix); let marked = self.marked_entries.contains(&ix); let status_style = GitPanelSettings::get_global(cx).status_style; @@ -3965,15 +3979,16 @@ impl GitPanel { let has_conflict = status.is_conflicted(); let is_modified = status.is_modified(); let is_deleted = status.is_deleted(); + let is_renamed = status.is_renamed(); let label_color = if status_style == StatusStyle::LabelColor { if has_conflict { Color::VersionControlConflict - } else if is_modified { - Color::VersionControlModified } else if is_deleted { // We don't want a bunch of red labels in the list Color::Disabled + } else if is_renamed || is_modified { + Color::VersionControlModified } else { Color::VersionControlAdded } @@ -3993,12 +4008,6 @@ impl GitPanel { let checkbox_id: ElementId = ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into()); - let active_repo = self - .project - .read(cx) - .active_repository(cx) - .expect("active repository must be set"); - let repo = active_repo.read(cx); // Checking for current staged/unstaged file status is a chained operation: // 1. first, we check for any pending operation recorded in repository // 2. if there are no pending ops either running or finished, we then ask the repository @@ -4153,23 +4162,32 @@ impl GitPanel { .items_center() .flex_1() // .overflow_hidden() - .when_some(entry.parent_dir(path_style), |this, parent| { - if !parent.is_empty() { - this.child( - self.entry_label( - format!("{parent}{}", path_style.separator()), - path_color, + .when_some(old_path.as_ref(), |this, old_path| { + let new_display = old_path.display(path_style).to_string(); + let old_display = entry.repo_path.display(path_style).to_string(); + this.child(self.entry_label(old_display, Color::Muted).strikethrough()) + .child(self.entry_label(" → ", Color::Muted)) + .child(self.entry_label(new_display, label_color)) + }) + .when(old_path.is_none(), |this| { + this.when_some(entry.parent_dir(path_style), |this, parent| { + if !parent.is_empty() { + this.child( + self.entry_label( + format!("{parent}{}", path_style.separator()), + path_color, + ) + .when(status.is_deleted(), |this| this.strikethrough()), ) + } else { + this + } + }) + .child( + self.entry_label(display_name, label_color) .when(status.is_deleted(), |this| this.strikethrough()), - ) - } else { - this - } - }) - .child( - self.entry_label(display_name, label_color) - .when(status.is_deleted(), |this| this.strikethrough()), - ), + ) + }), ) .into_any_element() } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index b4e833f7af72cf7843d3797b51ea349b24c7adc5..3a664b484a8ec6d31bd243917888d864280b281d 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -708,6 +708,11 @@ impl RenderOnce for GitStatusIcon { IconName::SquareMinus, cx.theme().colors().version_control_deleted, ) + } else if status.is_renamed() { + ( + IconName::ArrowRight, + cx.theme().colors().version_control_modified, + ) } else if status.is_modified() { ( IconName::SquareDot, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 4cac71c6ae3e2eb3f3615821443db7c82e01d810..94af9859df1156d7a10286a843a31e8351fe050c 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -256,6 +256,7 @@ pub struct RepositorySnapshot { pub id: RepositoryId, pub statuses_by_path: SumTree, pub pending_ops_by_path: SumTree, + pub renamed_paths: HashMap, pub work_directory_abs_path: Arc, pub path_style: PathStyle, pub branch: Option, @@ -3063,6 +3064,7 @@ impl RepositorySnapshot { id, statuses_by_path: Default::default(), pending_ops_by_path: Default::default(), + renamed_paths: HashMap::default(), work_directory_abs_path, branch: None, head_commit: None, @@ -3104,6 +3106,11 @@ impl RepositorySnapshot { .iter() .map(stash_to_proto) .collect(), + renamed_paths: self + .renamed_paths + .iter() + .map(|(new_path, old_path)| (new_path.to_proto(), old_path.to_proto())) + .collect(), } } @@ -3173,6 +3180,11 @@ impl RepositorySnapshot { .iter() .map(stash_to_proto) .collect(), + renamed_paths: self + .renamed_paths + .iter() + .map(|(new_path, old_path)| (new_path.to_proto(), old_path.to_proto())) + .collect(), } } @@ -4968,6 +4980,17 @@ impl Repository { } self.snapshot.stash_entries = new_stash_entries; + self.snapshot.renamed_paths = update + .renamed_paths + .into_iter() + .filter_map(|(new_path_str, old_path_str)| { + Some(( + RepoPath::from_proto(&new_path_str).log_err()?, + RepoPath::from_proto(&old_path_str).log_err()?, + )) + }) + .collect(); + let edits = update .removed_statuses .into_iter() @@ -5743,6 +5766,7 @@ async fn compute_snapshot( id, statuses_by_path, pending_ops_by_path, + renamed_paths: statuses.renamed_paths, work_directory_abs_path, path_style: prev_snapshot.path_style, scan_id: prev_snapshot.scan_id + 1, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index efbd7f616f9e75c4e0409f4dc73c67f9eb1836e0..8ed17864ec0c0403a4bb71f918d21b44a9b6cb13 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -124,6 +124,7 @@ message UpdateRepository { optional GitCommitDetails head_commit_details = 11; optional string merge_message = 12; repeated StashEntry stash_entries = 13; + map renamed_paths = 14; } message RemoveRepository {