add Loading Spinner when file History is loading
Make Renamed instad of Del and untracked
This commit is contained in:
+200
-9
@@ -389,7 +389,23 @@ pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String
|
||||
validate_files(&files)?;
|
||||
|
||||
if !files.is_empty() {
|
||||
run_git_with_paths(&repo, &["add"], &files)?;
|
||||
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
|
||||
// to be staged with both its old and new path so `git add` records it as a rename
|
||||
// instead of leaving the old path's deletion unstaged.
|
||||
let current_status = status_for_repo(&repo)?;
|
||||
let mut add_paths: Vec<String> = Vec::new();
|
||||
for file in &files {
|
||||
match find_status(¤t_status.files, file) {
|
||||
Some(entry) => {
|
||||
if let Some(old_path) = &entry.old_path {
|
||||
add_paths.push(old_path.clone());
|
||||
}
|
||||
add_paths.push(entry.path.clone());
|
||||
}
|
||||
None => add_paths.push(file.clone()),
|
||||
}
|
||||
}
|
||||
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
||||
}
|
||||
|
||||
status_for_repo(&repo)
|
||||
@@ -1415,7 +1431,8 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
"--untracked-files=all",
|
||||
],
|
||||
)?;
|
||||
let (branch, files) = parse_status_output(&output)?;
|
||||
let (branch, mut files) = parse_status_output(&output)?;
|
||||
detect_worktree_renames(repo, &mut files);
|
||||
|
||||
Ok(GitStatus {
|
||||
repo_path: repo.to_string_lossy().to_string(),
|
||||
@@ -1428,6 +1445,127 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
})
|
||||
}
|
||||
|
||||
// `git status` only auto-detects renames between HEAD and the index (staged changes).
|
||||
// A file renamed on disk but not yet `git add`ed shows up as a plain delete + untracked
|
||||
// pair instead. We detect that case ourselves by comparing content hashes: if an unstaged
|
||||
// deletion and an untracked file share the same blob hash (and the match is unambiguous),
|
||||
// we merge them into a single unstaged "renamed" entry, mirroring how git reports staged
|
||||
// renames. This is intentionally content-hash based (not similarity-based) so it never
|
||||
// mutates the repository's real index.
|
||||
const WORKTREE_RENAME_DETECTION_LIMIT: usize = 300;
|
||||
|
||||
fn detect_worktree_renames(repo: &Path, files: &mut Vec<GitFileStatus>) {
|
||||
let deleted_paths: Vec<String> = files
|
||||
.iter()
|
||||
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
|
||||
.map(|f| f.path.clone())
|
||||
.collect();
|
||||
let untracked_paths: Vec<String> = files
|
||||
.iter()
|
||||
.filter(|f| f.staged.is_none() && f.unstaged == Some(FileStatusKind::Untracked))
|
||||
.map(|f| f.path.clone())
|
||||
.collect();
|
||||
|
||||
if deleted_paths.is_empty()
|
||||
|| untracked_paths.is_empty()
|
||||
|| deleted_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
||||
|| untracked_paths.len() > WORKTREE_RENAME_DETECTION_LIMIT
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(deleted_hashes) = index_blob_hashes(repo, &deleted_paths) else {
|
||||
return;
|
||||
};
|
||||
let Ok(untracked_hashes) = worktree_blob_hashes(repo, &untracked_paths) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut hash_to_deleted: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (path, hash) in &deleted_hashes {
|
||||
hash_to_deleted.entry(hash).or_default().push(path);
|
||||
}
|
||||
let mut hash_to_untracked: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (path, hash) in &untracked_hashes {
|
||||
hash_to_untracked.entry(hash).or_default().push(path);
|
||||
}
|
||||
|
||||
let mut renames: Vec<(String, String)> = Vec::new();
|
||||
for (hash, olds) in &hash_to_deleted {
|
||||
if olds.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
if let Some(news) = hash_to_untracked.get(hash) {
|
||||
if news.len() == 1 {
|
||||
renames.push((olds[0].to_string(), news[0].to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (old_path, new_path) in renames {
|
||||
files.retain(|f| {
|
||||
!(f.path == old_path && f.staged.is_none() && f.unstaged == Some(FileStatusKind::Deleted))
|
||||
&& !(f.path == new_path
|
||||
&& f.staged.is_none()
|
||||
&& f.unstaged == Some(FileStatusKind::Untracked))
|
||||
});
|
||||
files.push(GitFileStatus {
|
||||
path: new_path,
|
||||
old_path: Some(old_path),
|
||||
staged: None,
|
||||
unstaged: Some(FileStatusKind::Renamed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batched via `git ls-files -s -z` (one process for every deleted path) rather than one
|
||||
// `git rev-parse` call per file, since this runs on every status refresh.
|
||||
fn index_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
||||
let mut args: Vec<OsString> = vec![
|
||||
OsString::from("ls-files"),
|
||||
OsString::from("-s"),
|
||||
OsString::from("-z"),
|
||||
OsString::from("--"),
|
||||
];
|
||||
args.extend(paths.iter().map(OsString::from));
|
||||
let output = run_git(repo, args)?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for entry in output.split(|byte| *byte == 0).filter(|e| !e.is_empty()) {
|
||||
let text = String::from_utf8_lossy(entry);
|
||||
let Some((meta, path)) = text.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let Some(hash) = meta.split_whitespace().nth(1) else {
|
||||
continue;
|
||||
};
|
||||
result.push((path.to_string(), hash.to_string()));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Batched via `git hash-object --stdin-paths` (one process for every untracked path).
|
||||
fn worktree_blob_hashes(repo: &Path, paths: &[String]) -> Result<Vec<(String, String)>, String> {
|
||||
let stdin_data = paths.join("\n");
|
||||
let output = run_git_with_stdin(
|
||||
repo,
|
||||
["hash-object", "--stdin-paths"],
|
||||
stdin_data.as_bytes(),
|
||||
)?;
|
||||
|
||||
let hashes: Vec<String> = String::from_utf8_lossy(&output)
|
||||
.lines()
|
||||
.map(|line| line.trim().to_string())
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(paths
|
||||
.iter()
|
||||
.cloned()
|
||||
.zip(hashes)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
let status = status_for_repo(repo)?;
|
||||
repository_files_with_status(repo, &status)
|
||||
@@ -2207,13 +2345,22 @@ fn restore_worktree_files(
|
||||
|
||||
for file in files {
|
||||
let status = find_status(statuses, file);
|
||||
if matches!(
|
||||
status.and_then(|entry| entry.unstaged),
|
||||
Some(FileStatusKind::Untracked)
|
||||
) {
|
||||
clean_paths.push(file.clone());
|
||||
} else {
|
||||
restore_paths.push(file.clone());
|
||||
match status.and_then(|entry| entry.unstaged) {
|
||||
Some(FileStatusKind::Untracked) => clean_paths.push(file.clone()),
|
||||
// An unstaged rename (see `detect_worktree_renames`) has no index entry for the
|
||||
// new path, so `git restore` can't act on it directly: restore the original
|
||||
// content at the old path and drop the untracked new file instead.
|
||||
Some(FileStatusKind::Renamed) => {
|
||||
if let Some(entry) = status {
|
||||
if let Some(old_path) = entry.old_path.clone() {
|
||||
restore_paths.push(old_path);
|
||||
}
|
||||
clean_paths.push(entry.path.clone());
|
||||
} else {
|
||||
restore_paths.push(file.clone());
|
||||
}
|
||||
}
|
||||
_ => restore_paths.push(file.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2633,6 +2780,50 @@ where
|
||||
Err(format!("{context}: {details}"))
|
||||
}
|
||||
|
||||
fn run_git_with_stdin<I, S>(repo: &Path, args: I, stdin_data: &[u8]) -> Result<Vec<u8>, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
use std::io::Write;
|
||||
|
||||
let mut child = git_command()
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(stdin_data)
|
||||
.map_err(|err| format!("Eingabe konnte nicht an Git gesendet werden: {err}"))?;
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("Git-Ausgabe konnte nicht gelesen werden: {err}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(output.stdout);
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let details = if !stderr.trim().is_empty() {
|
||||
stderr.trim()
|
||||
} else if !stdout.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
"unbekannter Fehler"
|
||||
};
|
||||
|
||||
Err(format!("Git-Befehl fehlgeschlagen: {details}"))
|
||||
}
|
||||
|
||||
fn parse_status_output(output: &[u8]) -> Result<(BranchInfo, Vec<GitFileStatus>), String> {
|
||||
let entries: Vec<&[u8]> = output
|
||||
.split(|byte| *byte == 0)
|
||||
|
||||
Reference in New Issue
Block a user