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
---
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 {