Fix track file renames in git panel (#42352)

ozzy created

Closes #30549

Release Notes:

- Fixed: Git renames now properly show as renamed files in the git panel
instead of appearing as deleted + untracked files
<img width="351" height="132" alt="Screenshot 2025-11-10 at 17 39 44"
src="https://github.com/user-attachments/assets/80e9c286-1abd-4498-a7d5-bd21633e6597"
/>
<img width="500" height="95" alt="Screenshot 2025-11-10 at 17 39 55"
src="https://github.com/user-attachments/assets/e4c59796-df3a-4d12-96f4-e6706b13a32f"
/>

Change summary

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(-)

Detailed changes

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(),
                     });
                 }
             }

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(),
                         });
                     }
                 }

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 {

crates/git/src/repository.rs 🔗

@@ -2045,7 +2045,7 @@ fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
         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(

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<RepoPath, RepoPath>,
 }
 
 impl FromStr for GitStatus {
     type Err = anyhow::Error;
 
     fn from_str(s: &str) -> Result<Self> {
-        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::<Vec<_>>();
+            }
+
+            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(),
         }
     }
 }

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()
     }

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,

crates/project/src/git_store.rs 🔗

@@ -256,6 +256,7 @@ pub struct RepositorySnapshot {
     pub id: RepositoryId,
     pub statuses_by_path: SumTree<StatusEntry>,
     pub pending_ops_by_path: SumTree<PendingOps>,
+    pub renamed_paths: HashMap<RepoPath, RepoPath>,
     pub work_directory_abs_path: Arc<Path>,
     pub path_style: PathStyle,
     pub branch: Option<Branch>,
@@ -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,

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<string, string> renamed_paths = 14;
 }
 
 message RemoveRepository {