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" />
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,6 +796,7 @@ impl Database {
|
||||
is_last_update: true,
|
||||
merge_message: db_repository.merge_message,
|
||||
stash_entries: Vec::new(),
|
||||
renamed_paths: Default::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
+75
-20
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(status.is_deleted(), |this| this.strikethrough()),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
.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))
|
||||
})
|
||||
.child(
|
||||
self.entry_label(display_name, label_color)
|
||||
.when(status.is_deleted(), |this| this.strikethrough()),
|
||||
),
|
||||
.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()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user