Skip to content

Commit b0a7def

Browse files
authored
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 <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" />
1 parent 57e3bcf commit b0a7def

File tree

9 files changed

+150
-44
lines changed

9 files changed

+150
-44
lines changed

crates/collab/src/db/queries/projects.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,7 @@ impl Database {
10051005
is_last_update: true,
10061006
merge_message: db_repository_entry.merge_message,
10071007
stash_entries: Vec::new(),
1008+
renamed_paths: Default::default(),
10081009
});
10091010
}
10101011
}

crates/collab/src/db/queries/rooms.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ impl Database {
796796
is_last_update: true,
797797
merge_message: db_repository.merge_message,
798798
stash_entries: Vec::new(),
799+
renamed_paths: Default::default(),
799800
});
800801
}
801802
}

crates/fs/src/fake_git_repo.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ impl GitRepository for FakeGitRepository {
359359
entries.sort_by(|a, b| a.0.cmp(&b.0));
360360
anyhow::Ok(GitStatus {
361361
entries: entries.into(),
362+
renamed_paths: HashMap::default(),
362363
})
363364
});
364365
Task::ready(match result {

crates/git/src/repository.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2045,7 +2045,7 @@ fn git_status_args(path_prefixes: &[RepoPath]) -> Vec<OsString> {
20452045
OsString::from("status"),
20462046
OsString::from("--porcelain=v1"),
20472047
OsString::from("--untracked-files=all"),
2048-
OsString::from("--no-renames"),
2048+
OsString::from("--find-renames"),
20492049
OsString::from("-z"),
20502050
];
20512051
args.extend(

crates/git/src/status.rs

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,14 @@ impl FileStatus {
203203
matches!(self, FileStatus::Untracked)
204204
}
205205

206+
pub fn is_renamed(self) -> bool {
207+
let FileStatus::Tracked(tracked) = self else {
208+
return false;
209+
};
210+
tracked.index_status == StatusCode::Renamed
211+
|| tracked.worktree_status == StatusCode::Renamed
212+
}
213+
206214
pub fn summary(self) -> GitSummary {
207215
match self {
208216
FileStatus::Ignored => GitSummary::UNCHANGED,
@@ -430,34 +438,79 @@ impl std::ops::Sub for GitSummary {
430438
#[derive(Clone, Debug)]
431439
pub struct GitStatus {
432440
pub entries: Arc<[(RepoPath, FileStatus)]>,
441+
pub renamed_paths: HashMap<RepoPath, RepoPath>,
433442
}
434443

435444
impl FromStr for GitStatus {
436445
type Err = anyhow::Error;
437446

438447
fn from_str(s: &str) -> Result<Self> {
439-
let mut entries = s
440-
.split('\0')
441-
.filter_map(|entry| {
442-
let sep = entry.get(2..3)?;
443-
if sep != " " {
444-
return None;
448+
let mut parts = s.split('\0').peekable();
449+
let mut entries = Vec::new();
450+
let mut renamed_paths = HashMap::default();
451+
452+
while let Some(entry) = parts.next() {
453+
if entry.is_empty() {
454+
continue;
455+
}
456+
457+
if !matches!(entry.get(2..3), Some(" ")) {
458+
continue;
459+
}
460+
461+
let path_or_old_path = &entry[3..];
462+
463+
if path_or_old_path.ends_with('/') {
464+
continue;
465+
}
466+
467+
let status = match entry.as_bytes()[0..2].try_into() {
468+
Ok(bytes) => match FileStatus::from_bytes(bytes).log_err() {
469+
Some(s) => s,
470+
None => continue,
471+
},
472+
Err(_) => continue,
473+
};
474+
475+
let is_rename = matches!(
476+
status,
477+
FileStatus::Tracked(TrackedStatus {
478+
index_status: StatusCode::Renamed | StatusCode::Copied,
479+
..
480+
}) | FileStatus::Tracked(TrackedStatus {
481+
worktree_status: StatusCode::Renamed | StatusCode::Copied,
482+
..
483+
})
484+
);
485+
486+
let (old_path_str, new_path_str) = if is_rename {
487+
let new_path = match parts.next() {
488+
Some(new_path) if !new_path.is_empty() => new_path,
489+
_ => continue,
445490
};
446-
let path = &entry[3..];
447-
// The git status output includes untracked directories as well as untracked files.
448-
// We do our own processing to compute the "summary" status of each directory,
449-
// so just skip any directories in the output, since they'll otherwise interfere
450-
// with our handling of nested repositories.
451-
if path.ends_with('/') {
452-
return None;
491+
(path_or_old_path, new_path)
492+
} else {
493+
(path_or_old_path, path_or_old_path)
494+
};
495+
496+
if new_path_str.ends_with('/') {
497+
continue;
498+
}
499+
500+
let new_path = match RelPath::unix(new_path_str).log_err() {
501+
Some(p) => RepoPath::from_rel_path(p),
502+
None => continue,
503+
};
504+
505+
if is_rename {
506+
if let Some(old_path_rel) = RelPath::unix(old_path_str).log_err() {
507+
let old_path_repo = RepoPath::from_rel_path(old_path_rel);
508+
renamed_paths.insert(new_path.clone(), old_path_repo);
453509
}
454-
let status = entry.as_bytes()[0..2].try_into().unwrap();
455-
let status = FileStatus::from_bytes(status).log_err()?;
456-
// git-status outputs `/`-delimited repo paths, even on Windows.
457-
let path = RepoPath::from_rel_path(RelPath::unix(path).log_err()?);
458-
Some((path, status))
459-
})
460-
.collect::<Vec<_>>();
510+
}
511+
512+
entries.push((new_path, status));
513+
}
461514
entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
462515
// When a file exists in HEAD, is deleted in the index, and exists again in the working copy,
463516
// git produces two lines for it, one reading `D ` (deleted in index, unmodified in working copy)
@@ -481,6 +534,7 @@ impl FromStr for GitStatus {
481534
});
482535
Ok(Self {
483536
entries: entries.into(),
537+
renamed_paths,
484538
})
485539
}
486540
}
@@ -489,6 +543,7 @@ impl Default for GitStatus {
489543
fn default() -> Self {
490544
Self {
491545
entries: Arc::new([]),
546+
renamed_paths: HashMap::default(),
492547
}
493548
}
494549
}

crates/git_ui/src/git_panel.rs

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3957,6 +3957,20 @@ impl GitPanel {
39573957
let path_style = self.project.read(cx).path_style(cx);
39583958
let display_name = entry.display_name(path_style);
39593959

3960+
let active_repo = self
3961+
.project
3962+
.read(cx)
3963+
.active_repository(cx)
3964+
.expect("active repository must be set");
3965+
let repo = active_repo.read(cx);
3966+
let repo_snapshot = repo.snapshot();
3967+
3968+
let old_path = if entry.status.is_renamed() {
3969+
repo_snapshot.renamed_paths.get(&entry.repo_path)
3970+
} else {
3971+
None
3972+
};
3973+
39603974
let selected = self.selected_entry == Some(ix);
39613975
let marked = self.marked_entries.contains(&ix);
39623976
let status_style = GitPanelSettings::get_global(cx).status_style;
@@ -3965,15 +3979,16 @@ impl GitPanel {
39653979
let has_conflict = status.is_conflicted();
39663980
let is_modified = status.is_modified();
39673981
let is_deleted = status.is_deleted();
3982+
let is_renamed = status.is_renamed();
39683983

39693984
let label_color = if status_style == StatusStyle::LabelColor {
39703985
if has_conflict {
39713986
Color::VersionControlConflict
3972-
} else if is_modified {
3973-
Color::VersionControlModified
39743987
} else if is_deleted {
39753988
// We don't want a bunch of red labels in the list
39763989
Color::Disabled
3990+
} else if is_renamed || is_modified {
3991+
Color::VersionControlModified
39773992
} else {
39783993
Color::VersionControlAdded
39793994
}
@@ -3993,12 +4008,6 @@ impl GitPanel {
39934008
let checkbox_id: ElementId =
39944009
ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
39954010

3996-
let active_repo = self
3997-
.project
3998-
.read(cx)
3999-
.active_repository(cx)
4000-
.expect("active repository must be set");
4001-
let repo = active_repo.read(cx);
40024011
// Checking for current staged/unstaged file status is a chained operation:
40034012
// 1. first, we check for any pending operation recorded in repository
40044013
// 2. if there are no pending ops either running or finished, we then ask the repository
@@ -4153,23 +4162,32 @@ impl GitPanel {
41534162
.items_center()
41544163
.flex_1()
41554164
// .overflow_hidden()
4156-
.when_some(entry.parent_dir(path_style), |this, parent| {
4157-
if !parent.is_empty() {
4158-
this.child(
4159-
self.entry_label(
4160-
format!("{parent}{}", path_style.separator()),
4161-
path_color,
4165+
.when_some(old_path.as_ref(), |this, old_path| {
4166+
let new_display = old_path.display(path_style).to_string();
4167+
let old_display = entry.repo_path.display(path_style).to_string();
4168+
this.child(self.entry_label(old_display, Color::Muted).strikethrough())
4169+
.child(self.entry_label(" → ", Color::Muted))
4170+
.child(self.entry_label(new_display, label_color))
4171+
})
4172+
.when(old_path.is_none(), |this| {
4173+
this.when_some(entry.parent_dir(path_style), |this, parent| {
4174+
if !parent.is_empty() {
4175+
this.child(
4176+
self.entry_label(
4177+
format!("{parent}{}", path_style.separator()),
4178+
path_color,
4179+
)
4180+
.when(status.is_deleted(), |this| this.strikethrough()),
41624181
)
4182+
} else {
4183+
this
4184+
}
4185+
})
4186+
.child(
4187+
self.entry_label(display_name, label_color)
41634188
.when(status.is_deleted(), |this| this.strikethrough()),
4164-
)
4165-
} else {
4166-
this
4167-
}
4168-
})
4169-
.child(
4170-
self.entry_label(display_name, label_color)
4171-
.when(status.is_deleted(), |this| this.strikethrough()),
4172-
),
4189+
)
4190+
}),
41734191
)
41744192
.into_any_element()
41754193
}

crates/git_ui/src/git_ui.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,11 @@ impl RenderOnce for GitStatusIcon {
708708
IconName::SquareMinus,
709709
cx.theme().colors().version_control_deleted,
710710
)
711+
} else if status.is_renamed() {
712+
(
713+
IconName::ArrowRight,
714+
cx.theme().colors().version_control_modified,
715+
)
711716
} else if status.is_modified() {
712717
(
713718
IconName::SquareDot,

crates/project/src/git_store.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ pub struct RepositorySnapshot {
256256
pub id: RepositoryId,
257257
pub statuses_by_path: SumTree<StatusEntry>,
258258
pub pending_ops_by_path: SumTree<PendingOps>,
259+
pub renamed_paths: HashMap<RepoPath, RepoPath>,
259260
pub work_directory_abs_path: Arc<Path>,
260261
pub path_style: PathStyle,
261262
pub branch: Option<Branch>,
@@ -3063,6 +3064,7 @@ impl RepositorySnapshot {
30633064
id,
30643065
statuses_by_path: Default::default(),
30653066
pending_ops_by_path: Default::default(),
3067+
renamed_paths: HashMap::default(),
30663068
work_directory_abs_path,
30673069
branch: None,
30683070
head_commit: None,
@@ -3104,6 +3106,11 @@ impl RepositorySnapshot {
31043106
.iter()
31053107
.map(stash_to_proto)
31063108
.collect(),
3109+
renamed_paths: self
3110+
.renamed_paths
3111+
.iter()
3112+
.map(|(new_path, old_path)| (new_path.to_proto(), old_path.to_proto()))
3113+
.collect(),
31073114
}
31083115
}
31093116

@@ -3173,6 +3180,11 @@ impl RepositorySnapshot {
31733180
.iter()
31743181
.map(stash_to_proto)
31753182
.collect(),
3183+
renamed_paths: self
3184+
.renamed_paths
3185+
.iter()
3186+
.map(|(new_path, old_path)| (new_path.to_proto(), old_path.to_proto()))
3187+
.collect(),
31763188
}
31773189
}
31783190

@@ -4968,6 +4980,17 @@ impl Repository {
49684980
}
49694981
self.snapshot.stash_entries = new_stash_entries;
49704982

4983+
self.snapshot.renamed_paths = update
4984+
.renamed_paths
4985+
.into_iter()
4986+
.filter_map(|(new_path_str, old_path_str)| {
4987+
Some((
4988+
RepoPath::from_proto(&new_path_str).log_err()?,
4989+
RepoPath::from_proto(&old_path_str).log_err()?,
4990+
))
4991+
})
4992+
.collect();
4993+
49714994
let edits = update
49724995
.removed_statuses
49734996
.into_iter()
@@ -5743,6 +5766,7 @@ async fn compute_snapshot(
57435766
id,
57445767
statuses_by_path,
57455768
pending_ops_by_path,
5769+
renamed_paths: statuses.renamed_paths,
57465770
work_directory_abs_path,
57475771
path_style: prev_snapshot.path_style,
57485772
scan_id: prev_snapshot.scan_id + 1,

crates/proto/proto/git.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ message UpdateRepository {
124124
optional GitCommitDetails head_commit_details = 11;
125125
optional string merge_message = 12;
126126
repeated StashEntry stash_entries = 13;
127+
map<string, string> renamed_paths = 14;
127128
}
128129

129130
message RemoveRepository {

0 commit comments

Comments
 (0)