fix(vcs): decode git blobs in the file's encoding (#301)

Git blobs were always decoded as UTF-8, so files in GBK or other legacy encodings showed every line as changed in the gutter, diff, blame and commit views, and staging/committing could re-encode them wrongly.

Blob text is now decoded and encoded with the file's encoding: the open document's encoding, else detection on the working file with the project default as fallback, else detection on the blob. Covers line hunks, file-at-revision, blame, stage patch/hunks, commit, conflict versions, diff text, and the agent worktree's file_at, partial commit and patch helpers. Text that cannot be represented in the target encoding is reported as an error instead of being written lossily.

Fixes #243

未在 Windows 实机验证。
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 09:00:21 +08:00
1 джерело 181a2ea03e
коміт cdb6cc7c0d
17 files changed
+714 -120

No files matched your search

Створено
+4
Переглянути файл
@@ -4034,10 +4034,12 @@ name = "ide-agent"
version = "0.1.25"
dependencies = [
"dunce",
"encoding_rs",
"ide-core",
"ide-jsonrpc",
"ide-tools",
"ide-vcs",
"ide-vfs",
"parking_lot",
"serde",
"serde_json",
@@ -4202,6 +4204,7 @@ version = "0.1.25"
dependencies = [
"dirs 6.0.0",
"dunce",
"encoding_rs",
"ide-agent",
"ide-analysis",
"ide-build",
@@ -4603,6 +4606,7 @@ name = "ide-vcs"
version = "0.1.25"
dependencies = [
"dunce",
"encoding_rs",
"gix",
"ide-core",
"ignore",
+2
Переглянути файл
@@ -6,6 +6,8 @@ edition.workspace = true
license.workspace = true
[dependencies]
encoding_rs.workspace = true
ide-vfs.workspace = true
ide-core.workspace = true
ide-jsonrpc.workspace = true
ide-vcs.workspace = true
+6 -1
Переглянути файл
@@ -96,7 +96,12 @@ pub(crate) fn without_untouched(changes: Vec<AgentChange>, before: &BTreeMap<Str
}
pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult<Option<String>> {
repo_at(cwd)?.file_at(path, Some(rev))
let repo = repo_at(cwd)?;
let encoding = match ide_vfs::load_text(path) {
Ok(loaded) => encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()).unwrap_or(encoding_rs::UTF_8),
Err(_) => ide_vfs::detect_encoding(&repo.file_bytes_at(path, Some(rev))?.unwrap_or_default(), None),
};
repo.file_at(path, Some(rev), encoding)
}
/// A short ASCII branch-safe name from the task title.
+1
Переглянути файл
@@ -10,6 +10,7 @@ name = "openidea-server"
path = "src/bin/server.rs"
[dependencies]
encoding_rs.workspace = true
ide-core.workspace = true
ide-text.workspace = true
ide-vfs.workspace = true
+23 -12
Переглянути файл
@@ -187,18 +187,24 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
let project = host.project(window)?;
let text = project.text_of(&path)?;
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
}
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
blocking(move || {
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
})
.await
}
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
let project = host.project(window)?;
let text = project.documents.text_of(&path);
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
blocking(move || {
project.with_repo_of(&path, |r| r.blame(&path, text.as_deref(), project.vcs_encoding(&path, None)?))
})
.await
}
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
@@ -276,7 +282,7 @@ pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeR
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await
blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await
}
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
@@ -293,7 +299,12 @@ pub async fn vcs_commit(
) -> IdeResult<String> {
// Commit hooks may run for a long time.
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| {
r.commit_with(&message, &options, &|path| project.vcs_encoding(path, None))
})
})
.await
}
pub async fn vcs_checkout(
@@ -464,12 +475,14 @@ pub async fn vcs_remote(
/// The user's side is `ours` (sides swapped during a rebase, as in IDEA).
pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_vcs::MergeVersions> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path, project.vcs_encoding(&path, Some(":2"))?)))
.await
}
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged, project.vcs_encoding(&path, None)?)))
.await
}
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
@@ -481,13 +494,11 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
let project = host.project(window)?;
let current = ide_vfs::load_text(&path)?.text;
let encoding = project.vcs_encoding(&path, Some(":0"))?;
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
project.with_repo_of(&path, |r| {
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
let patch = ide_vcs::build_partial_patch(&rel, &index_text, &current, &selected);
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
r.stage_hunks(&path, &current, &selected, encoding)
})
}
+19 -1
Переглянути файл
@@ -689,11 +689,29 @@ impl Project {
}
}
/// Use the open document's selected encoding, or the same detection/settings as opening it.
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
if let Some(id) = self.documents.by_path(path) {
let info = self.documents.snapshot(id)?;
return encoding_rs::Encoding::for_label(info.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown document encoding"));
}
let defaults = self.documents.file_defaults();
let fallback = defaults.fallback_for(path);
if path.exists() {
let loaded = ide_vfs::load_text_with_fallback(path, fallback)?;
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
}
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
Ok(ide_vfs::detect_encoding(&bytes, fallback))
}
/// Current text of `path`: the open buffer when available, else the file on disk.
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
match self.documents.text_of(path) {
Some(text) => Ok(text),
None => Ok(ide_vfs::load_text(path)?.text),
None => Ok(ide_vfs::load_text_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
}
}
}
+111
Переглянути файл
@@ -0,0 +1,111 @@
use ide_core::{ChangeSet, NullSink, TextChange};
use ide_host::{Host, HostConfig, ops::vcs};
use std::{path::Path, process::Command, sync::Arc, time::Duration};
fn git(root: &Path, args: &[&str]) {
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
git(&root, &["init", "-b", "main"]);
git(&root, &["config", "user.name", "Test"]);
git(&root, &["config", "user.email", "[email protected]"]);
git(&root, &["config", "commit.gpgsign", "false"]);
git(&root, &["config", "core.autocrlf", "false"]);
let path = root.join("Example.java");
let old = "// 中文注释\nclass Example {\n // 保持不变\n int value = 1;\n}\n";
let original = ide_vfs::encode(old, "gbk", false).unwrap();
std::fs::write(&path, &original).unwrap();
std::fs::write(root.join("Utf8.java"), old).unwrap();
git(&root, &["add", "."]);
git(&root, &["commit", "-m", "GBK Java"]);
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
for _ in 0..500 {
if !project.repos().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "gbk".into();
project.documents.set_file_defaults(defaults);
assert_eq!(project.text_of(&path).unwrap(), old);
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(old));
let doc = project.documents.open(&path).unwrap();
assert_eq!(doc.encoding, "gbk");
// A live document's explicit selection wins over later project defaults.
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "windows-1252".into();
project.documents.set_file_defaults(defaults);
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
let from = old.find("value = 1").unwrap() + "value = ".len();
let offset = old[..from].encode_utf16().count() as u32;
project
.documents
.edit(
doc.id,
doc.version,
ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] },
)
.unwrap();
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap();
assert_eq!(hunks.len(), 1);
assert_eq!((hunks[0].start, hunks[0].lines), (3, 1));
assert_eq!(hunks[0].old_text, " int value = 1;\n");
project.documents.save(doc.id).unwrap();
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).unwrap();
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
assert_eq!(
repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(),
ide_vfs::encode(&old.replace("value = 1", "value = 2"), "gbk", false).unwrap()
);
repo.unstage(std::slice::from_ref(&path)).unwrap();
let snapshot = project.documents.snapshot(doc.id).unwrap();
let line_start =
snapshot.text.split_inclusive('\n').take(3).map(|s| s.encode_utf16().count()).sum::<usize>() as u32;
project
.documents
.edit(
doc.id,
snapshot.version,
ChangeSet {
changes: vec![TextChange {
from: line_start,
to: line_start + " int value = 2;\n".encode_utf16().count() as u32,
insert: hunks[0].old_text.clone(),
}],
},
)
.unwrap();
project.documents.save(doc.id).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), original);
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
assert!(vcs::vcs_blame(&host, "main", path.clone()).await.unwrap().iter().all(|l| l.author == "Test"));
// Missing working files still use configured encoding for historical content.
project.documents.close(doc.id);
let mut defaults = project.documents.file_defaults();
defaults.project_fallback = "gbk".into();
project.documents.set_file_defaults(defaults);
std::fs::remove_file(&path).unwrap();
assert_eq!(vcs::vcs_file_at(&host, "main", path, None).await.unwrap().as_deref(), Some(old));
let utf8 = root.join("Utf8.java");
std::fs::remove_file(&utf8).unwrap();
assert_eq!(vcs::vcs_file_at(&host, "main", utf8, None).await.unwrap().as_deref(), Some(old));
host.close_project("main");
}
+1
Переглянути файл
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
encoding_rs.workspace = true
dunce.workspace = true
ide-core.workspace = true
ignore.workspace = true
+39
Переглянути файл
@@ -174,6 +174,45 @@ fn quote_path(path: &str) -> String {
out
}
/// Apply selected line hunks while retaining the base's line separators.
pub(crate) fn selected_text(old: &str, new: &str, selected: &[usize]) -> String {
let old_norm = normalize_eol(old);
let new_norm = normalize_eol(new);
let old_lines = split_lines(&old_norm);
let new_lines = split_lines(&new_norm);
let old_raw = split_raw_lines(old);
let eol = if old.contains("\r\n") {
"\r\n"
} else if old.contains('\r') {
"\r"
} else {
"\n"
};
let mut result = String::new();
let mut cursor = 0;
for (index, (before, after)) in raw_hunks(&old_lines, &new_lines).iter().enumerate() {
if !selected.contains(&index) {
continue;
}
for line in &old_raw[cursor..before.start as usize] {
result.push_str(line);
}
for line in &new_lines[after.start as usize..after.end as usize] {
if let Some(body) = line.strip_suffix('\n') {
result.push_str(body);
result.push_str(eol);
} else {
result.push_str(line);
}
}
cursor = before.end as usize;
}
for line in &old_raw[cursor..] {
result.push_str(line);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
+54 -18
Переглянути файл
@@ -2,6 +2,7 @@
//! writes (commit, checkout, push/pull, stash, merge) shell out to `git` so
//! hooks, credentials and signing behave exactly as on the command line.
use encoding_rs::Encoding;
use ide_core::ignored::IgnoredNames;
use ide_core::{CancellationToken, IdeResult};
use serde::{Deserialize, Serialize};
@@ -451,6 +452,7 @@ mod repo;
mod roots;
pub mod shelf;
mod status;
mod text_encoding;
mod write;
pub use cli::{detected_git_executable, git_version, set_askpass_env, set_git_executable};
@@ -520,10 +522,24 @@ impl Repo {
}
/// Content of `path` at `rev` (default HEAD); `None` if absent there.
/// `rev` may be any revision git understands, or `":"` / `":0"` for the index.
pub fn file_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
pub fn file_at(&self, path: &Path, rev: Option<&str>, encoding: &'static Encoding) -> IdeResult<Option<String>> {
let rel = self.rel_path(path)?;
let repo = self.gix()?;
Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| String::from_utf8_lossy(&b).into_owned()))
Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
}
/// Raw revision bytes, for encoding detection when the working file is absent.
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
repo::blob_at(&self.gix()?, &self.rel_path(path)?, rev.unwrap_or("HEAD"))
}
/// Stage selected decoded hunks, retaining the index encoding, BOM and line endings.
pub fn stage_hunks(
&self,
path: &Path,
current: &str,
selected: &[usize],
encoding: &'static Encoding,
) -> IdeResult<()> {
write::stage_hunks(self, path, current, selected, encoding)
}
/// Hunks of `current_text` against HEAD (or the index when `against_index`).
///
@@ -531,7 +547,13 @@ impl Repo {
/// follows the removed block (equal to the line count when removed at the end).
/// Files absent from the base yield one `Added` hunk; ignored files, binary files and
/// paths outside the worktree yield no hunks.
pub fn line_hunks(&self, path: &Path, current_text: &str, against_index: bool) -> IdeResult<Vec<LineHunk>> {
pub fn line_hunks(
&self,
path: &Path,
current_text: &str,
against_index: bool,
encoding: &'static Encoding,
) -> IdeResult<Vec<LineHunk>> {
let Ok(rel) = self.rel_path(path) else { return Ok(Vec::new()) };
if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
return Ok(Vec::new());
@@ -539,8 +561,8 @@ impl Repo {
let repo = self.gix()?;
let base = repo::blob_at(&repo, &rel, if against_index { ":0" } else { "HEAD" })?;
match base {
Some(base) if base.contains(&0) => Ok(Vec::new()),
Some(base) => Ok(diff::line_hunks(&String::from_utf8_lossy(&base), current_text)),
Some(base) if text_encoding::is_binary(&base, encoding) => Ok(Vec::new()),
Some(base) => Ok(diff::line_hunks(&text_encoding::decode(&base, encoding), current_text)),
None => {
if repo::is_ignored(&repo, &rel)? {
return Ok(Vec::new());
@@ -551,8 +573,13 @@ impl Repo {
}
/// One entry per line of the current text (`current_text`, or the file on disk).
/// Lines not yet committed carry an all-zero commit id and author "Not Committed Yet".
pub fn blame(&self, path: &Path, current_text: Option<&str>) -> IdeResult<Vec<BlameLine>> {
write::blame(self, path, current_text)
pub fn blame(
&self,
path: &Path,
current_text: Option<&str>,
encoding: &'static Encoding,
) -> IdeResult<Vec<BlameLine>> {
write::blame(self, path, current_text, encoding)
}
/// Commits newest-first in topological order (children before parents).
///
@@ -583,8 +610,8 @@ impl Repo {
}
/// Unified diff of a file: index↔worktree (`staged == false`, untracked files diff
/// against /dev/null) or HEAD↔index (`staged == true`). Empty when unchanged.
pub fn diff_text(&self, path: &Path, staged: bool) -> IdeResult<String> {
write::diff_text(self, path, staged)
pub fn diff_text(&self, path: &Path, staged: bool, encoding: &'static Encoding) -> IdeResult<String> {
write::diff_text(self, path, staged, encoding)
}
/// `git config --get <key>` (all scopes); `None` when unset.
@@ -602,8 +629,8 @@ impl Repo {
}
/// Applies a unified-diff patch to the index (partial staging of hunks/lines).
/// Zero-context patches (see [`build_partial_patch`]) are accepted.
pub fn stage_patch(&self, patch: &str) -> IdeResult<()> {
write::stage_patch(self, patch)
pub fn stage_patch(&self, patch: &str, encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>) -> IdeResult<()> {
write::stage_patch(self, patch, encoding)
}
/// Discards unstaged changes: tracked paths are restored from the index (staged
/// changes are kept), untracked files and directories are deleted.
@@ -614,11 +641,16 @@ impl Repo {
/// `amend` keeps the previous message.
pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult<String> {
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() };
write::commit(self, message, &options)
write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8))
}
/// Commits with the Commit Options popup's choices.
pub fn commit_with(&self, message: &str, options: &CommitOptions) -> IdeResult<String> {
write::commit(self, message, options)
pub fn commit_with(
&self,
message: &str,
options: &CommitOptions,
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
) -> IdeResult<String> {
write::commit(self, message, options, encoding)
}
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
@@ -867,8 +899,8 @@ impl Repo {
}
/// The versions of a conflicted file with the user's work as `ours`, as IDEA's merge
/// dialog shows them (sides swapped during a rebase).
pub fn merge_versions(&self, path: &Path) -> IdeResult<MergeVersions> {
let (base, ours, theirs) = self.conflict_versions(path)?;
pub fn merge_versions(&self, path: &Path, encoding: &'static Encoding) -> IdeResult<MergeVersions> {
let (base, ours, theirs) = self.conflict_versions(path, encoding)?;
let sides = self.merge_sides()?;
let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) };
let merged = match (&ours, &theirs) {
@@ -878,11 +910,15 @@ impl Repo {
Ok(MergeVersions { base, ours, theirs, sides, merged })
}
/// For conflict resolution: (base, ours, theirs) versions of a conflicted file.
pub fn conflict_versions(&self, path: &Path) -> IdeResult<(Option<String>, Option<String>, Option<String>)> {
pub fn conflict_versions(
&self,
path: &Path,
encoding: &'static Encoding,
) -> IdeResult<(Option<String>, Option<String>, Option<String>)> {
let rel = self.rel_path(path)?;
let repo = self.gix()?;
let stage = |n: u8| -> IdeResult<Option<String>> {
Ok(repo::blob_at(&repo, &rel, &format!(":{n}"))?.map(|b| String::from_utf8_lossy(&b).into_owned()))
Ok(repo::blob_at(&repo, &rel, &format!(":{n}"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
};
Ok((stage(1)?, stage(2)?, stage(3)?))
}
+67 -4
Переглянути файл
@@ -50,11 +50,12 @@ pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> Id
let mut add: Vec<&str> = vec!["--literal-pathspecs", "add", "-A", "--"];
add.extend(rel_paths.iter().map(String::as_str));
run_env(r.root(), &add, None, &env)?;
let mut diff = vec!["diff", "--cached", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff"];
let mut diff =
vec!["diff", "--cached", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff", "--no-textconv"];
if reverse {
diff.push("-R");
}
let patch = run_env(r.root(), &diff, None, &env)?.stdout_str();
let patch = lossless_diff(r, &diff, &env)?;
let names = run_env(r.root(), &["diff", "--cached", "--name-status", "-z", "--no-renames"], None, &env)?;
Ok((patch, crate::ops::parse_name_status(&names.stdout_str())))
}
@@ -74,12 +75,12 @@ pub(crate) fn commits_patch(r: &Repo, commits: &[String], reverse: bool) -> IdeR
let parent = commit.parent_ids().next().map(|p| p.detach().to_string());
(parent.unwrap_or_else(|| EMPTY_TREE.to_string()), id.to_string())
};
let mut args = vec!["diff", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff"];
let mut args = vec!["diff", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff", "--no-textconv"];
if reverse {
args.push("-R");
}
args.extend([parent.as_str(), id.as_str(), "--"]);
out.push_str(&run(r.root(), &args, None)?.stdout_str());
out.push_str(&lossless_diff(r, &args, &[])?);
}
Ok(out)
}
@@ -266,6 +267,68 @@ pub(crate) fn parse_patch(patch: &str) -> Vec<PatchFile> {
out
}
/// A patch crosses the string IPC boundary. Non-UTF-8 content must travel as a
/// Git binary patch, not replacement characters or an encoding-dependent string.
fn lossless_diff(r: &Repo, args: &[&str], env: &[(&str, &OsStr)]) -> IdeResult<String> {
let output = run_env(r.root(), args, None, env)?.stdout;
if let Ok(text) = String::from_utf8(output) {
return Ok(text);
}
// info/attributes has highest precedence. Isolate it from the real repository
// so concurrent Git operations and user attributes are never modified.
let temp = tempfile::tempdir()?;
let git_dir = temp.path().to_string_lossy().into_owned();
let format = run(r.root(), &["rev-parse", "--show-object-format"], None)?.stdout_str();
run(r.root(), &["init", "--bare", "--quiet", &format!("--object-format={}", format.trim()), &git_dir], None)?;
std::fs::write(temp.path().join("info/attributes"), "* -diff\n")?;
if let Some(head) = r.head()?.1 {
std::fs::write(temp.path().join("HEAD"), format!("{head}\n"))?;
}
let objects = run(r.root(), &["rev-parse", "--path-format=absolute", "--git-path", "objects"], None)?.stdout_str();
let mut isolated = env.to_vec();
isolated.extend([
("GIT_DIR", temp.path().as_os_str()),
("GIT_WORK_TREE", r.root().as_os_str()),
("GIT_OBJECT_DIRECTORY", OsStr::new(objects.trim())),
]);
String::from_utf8(run_env(r.root(), args, None, &isolated)?.stdout)
.map_err(|_| IdeError::invalid("Git produced a non-UTF-8 binary patch"))
}
/// Encode only hunk payloads; patch headers and Git paths always remain UTF-8.
pub(crate) fn encode_text_patch(
r: &Repo,
patch: &str,
encoding: &dyn Fn(&std::path::Path) -> IdeResult<&'static encoding_rs::Encoding>,
) -> IdeResult<Vec<u8>> {
let mut out = Vec::new();
let mut header = String::new();
let mut enc = None;
for line in patch.split_inclusive('\n') {
if line.starts_with("diff --git ") {
header.clear();
enc = None;
}
if line.starts_with("@@ ") {
let file = parse_patch(&header)
.into_iter()
.next()
.ok_or_else(|| IdeError::invalid("patch hunk has no file header"))?;
enc = Some(encoding(&r.abs_path(&file.path))?);
} else if let Some(enc) = enc {
if line.starts_with(['+', '-', ' ']) {
out.push(line.as_bytes()[0]);
out.extend(crate::text_encoding::encode(&line[1..], enc, false)?);
continue;
}
} else {
header.push_str(line);
}
out.extend_from_slice(line.as_bytes());
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
+46
Переглянути файл
@@ -0,0 +1,46 @@
//! File content uses the document's encoding; Git metadata remains UTF-8.
use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
use ide_core::{IdeError, IdeResult};
use std::borrow::Cow;
pub(crate) fn has_bom(bytes: &[u8], encoding: &'static Encoding) -> bool {
Encoding::for_bom(bytes).is_some_and(|(enc, _)| enc == encoding)
}
pub(crate) fn decode<'a>(bytes: &'a [u8], encoding: &'static Encoding) -> Cow<'a, str> {
let body = match Encoding::for_bom(bytes) {
Some((enc, len)) if enc == encoding => &bytes[len..],
_ => bytes,
};
encoding.decode_without_bom_handling(body).0
}
pub(crate) fn is_binary(bytes: &[u8], encoding: &'static Encoding) -> bool {
encoding != UTF_16LE && encoding != UTF_16BE && bytes.contains(&0)
}
pub(crate) fn encode(text: &str, encoding: &'static Encoding, bom: bool) -> IdeResult<Vec<u8>> {
let mut out = Vec::new();
if encoding == UTF_16LE || encoding == UTF_16BE {
let le = encoding == UTF_16LE;
if bom {
out.extend_from_slice(if le { &[0xff, 0xfe] } else { &[0xfe, 0xff] });
}
for unit in text.encode_utf16() {
out.extend_from_slice(&if le { unit.to_le_bytes() } else { unit.to_be_bytes() });
}
} else {
let (bytes, _, errors) = encoding.encode(text);
if errors {
return Err(IdeError::invalid(format!(
"text contains characters not representable in {}",
encoding.name()
)));
}
if bom && encoding == UTF_8 {
out.extend_from_slice(&[0xef, 0xbb, 0xbf]);
}
out.extend_from_slice(&bytes);
}
Ok(out)
}
+109 -28
Переглянути файл
@@ -2,9 +2,11 @@
use crate::cli::{self, run};
use crate::repo::{gx, resolve};
use crate::text_encoding::{decode, encode, has_bom, is_binary};
use crate::{
BlameLine, CheckoutMode, CheckoutOptions, CheckoutOutcome, CommitOptions, DeletedBranch, DivergedLocal, Repo,
};
use encoding_rs::Encoding;
use gix::bstr::{BStr, ByteSlice};
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Path, PathBuf};
@@ -40,11 +42,33 @@ fn run_paths(r: &Repo, args: &[&str], paths: &[String]) -> IdeResult<()> {
run(r.root(), &full, None).map(|_| ())
}
pub(crate) fn blame(r: &Repo, path: &Path, current_text: Option<&str>) -> IdeResult<Vec<BlameLine>> {
pub(crate) fn blame(
r: &Repo,
path: &Path,
current_text: Option<&str>,
encoding: &'static Encoding,
) -> IdeResult<Vec<BlameLine>> {
let rel = r.rel_path(path)?;
let text: String = match current_text {
Some(t) => t.to_string(),
None => String::from_utf8_lossy(&std::fs::read(r.abs_path(&rel))?).into_owned(),
let disk = match std::fs::read(r.abs_path(&rel)) {
Ok(bytes) => bytes,
Err(_) if current_text.is_some() => Vec::new(),
Err(error) => return Err(error.into()),
};
let text = current_text.map(str::to_owned).unwrap_or_else(|| decode(&disk, encoding).into_owned());
let contents = match current_text {
Some(_) => {
let disk_text = decode(&disk, encoding);
let eol = if disk_text.contains("\r\n") {
"\r\n"
} else if disk_text.contains('\r') {
"\r"
} else {
"\n"
};
let contents = crate::diff::normalize_eol(&text).replace('\n', eol);
encode(&contents, encoding, has_bom(&disk, encoding))?
}
None => disk,
};
let line_count = text.split_inclusive('\n').count();
let tracked_in_head = {
@@ -57,7 +81,7 @@ pub(crate) fn blame(r: &Repo, path: &Path, current_text: Option<&str>) -> IdeRes
let out = run(
r.root(),
&["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", "--", &rel],
Some(text.as_bytes()),
Some(&contents),
)?;
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -87,7 +111,7 @@ fn uncommitted_lines(n: usize) -> Vec<BlameLine> {
.collect()
}
pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool) -> IdeResult<String> {
pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: &'static Encoding) -> IdeResult<String> {
let rel = r.rel_path(path)?;
let base =
["-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", "-c", "diff.external=", "--literal-pathspecs"];
@@ -102,7 +126,7 @@ pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool) -> IdeResult<String
args.extend(["diff", "--no-color", "--no-ext-diff", "--no-index", "--", "/dev/null", &rel]);
let out = cli::run_raw(r.root(), &args, None)?;
return match out.code {
Some(0) | Some(1) => Ok(out.stdout_str()),
Some(0) | Some(1) => Ok(decode_diff(&out.stdout, encoding)),
code => Err(cli::failure(&args, &out.stderr, &out.stdout, code)),
};
}
@@ -111,7 +135,7 @@ pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool) -> IdeResult<String
args.push("--cached");
}
args.extend(["--", &rel]);
Ok(run(r.root(), &args, None)?.stdout_str())
Ok(decode_diff(&run(r.root(), &args, None)?.stdout, encoding))
}
pub(crate) fn stage(r: &Repo, paths: &[PathBuf]) -> IdeResult<()> {
@@ -128,7 +152,11 @@ pub(crate) fn unstage(r: &Repo, paths: &[PathBuf]) -> IdeResult<()> {
}
}
pub(crate) fn stage_patch(r: &Repo, patch: &str) -> IdeResult<()> {
pub(crate) fn stage_patch(
r: &Repo,
patch: &str,
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
) -> IdeResult<()> {
if patch.trim().is_empty() {
return Ok(());
}
@@ -136,12 +164,9 @@ pub(crate) fn stage_patch(r: &Repo, patch: &str) -> IdeResult<()> {
if !patch.ends_with('\n') {
patch.push('\n');
}
run(
r.root(),
&["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"],
Some(patch.as_bytes()),
)
.map(|_| ())
let bytes = crate::patch::encode_text_patch(r, &patch, encoding)?;
run(r.root(), &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"], Some(&bytes))
.map(|_| ())
}
/// Paths with unresolved conflicts (index stages 1–3).
@@ -224,7 +249,12 @@ pub(crate) fn discard(r: &Repo, paths: &[PathBuf]) -> IdeResult<()> {
Ok(())
}
pub(crate) fn commit(r: &Repo, message: &str, options: &CommitOptions) -> IdeResult<String> {
pub(crate) fn commit(
r: &Repo,
message: &str,
options: &CommitOptions,
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
) -> IdeResult<String> {
let mut args = vec!["commit", "-q", "--cleanup=whitespace"];
if options.amend {
args.push("--amend");
@@ -252,7 +282,7 @@ pub(crate) fn commit(r: &Repo, message: &str, options: &CommitOptions) -> IdeRes
if options.partial.is_empty() {
run(r.root(), &args, stdin)?;
} else {
commit_partial(r, &args, stdin, &options.partial)?;
commit_partial(r, &args, stdin, &options.partial, encoding)?;
}
let repo = r.gix()?;
Ok(repo.head_id().map_err(gx)?.detach().to_string())
@@ -277,6 +307,7 @@ fn commit_partial(
commit_args: &[&str],
stdin: Option<&[u8]>,
partial: &[crate::PartialCommitFile],
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
) -> IdeResult<()> {
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
let index = r.git_dir.join("index");
@@ -295,10 +326,12 @@ fn commit_partial(
IdeError::invalid(format!("{rel}: only changes of a file in HEAD can be committed partially"))
})?;
let disk = std::fs::read(r.abs_path(&rel))?;
if head.contains(&0) || disk.contains(&0) {
let encoding = encoding(&file.path.0)?;
let bom = has_bom(&head, encoding);
if is_binary(&head, encoding) || is_binary(&disk, encoding) {
return Err(IdeError::invalid(format!("{rel}: binary files cannot be committed partially")));
}
let (head, disk) = (String::from_utf8_lossy(&head), String::from_utf8_lossy(&disk));
let (head, disk) = (decode(&head, encoding), decode(&disk, encoding));
let hunks = crate::diff::line_hunks(&head, &disk);
let mut selected = Vec::new();
for want in &file.hunks {
@@ -311,15 +344,9 @@ fn commit_partial(
}
}
cli::run_env(r.root(), &["--literal-pathspecs", "reset", "-q", "HEAD", "--", &rel], None, &env)?;
let patch = crate::diff::build_partial_patch(&rel, &head, &disk, &selected);
if !patch.is_empty() {
cli::run_env(
r.root(),
&["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"],
Some(patch.as_bytes()),
&env,
)?;
}
let text = crate::diff::selected_text(&head, &disk, &selected);
let bytes = encode(&text, encoding, bom)?;
write_index_blob(r, &rel, &bytes, &env)?;
rels.push(rel);
}
cli::run_env(r.root(), commit_args, stdin, &env)?;
@@ -798,3 +825,57 @@ fn push_remote(r: &Repo) -> IdeResult<String> {
})
.ok_or_else(|| IdeError::invalid("no remote configured"))
}
fn write_index_blob(r: &Repo, rel: &str, bytes: &[u8], env: &[(&str, &std::ffi::OsStr)]) -> IdeResult<()> {
let entry =
cli::run_env(r.root(), &["--literal-pathspecs", "ls-files", "--stage", "--", rel], None, env)?.stdout_str();
let mode = entry.split_whitespace().next().unwrap_or("100644");
let id = run(r.root(), &["hash-object", "-w", "--stdin"], Some(bytes))?.stdout_str();
cli::run_env(r.root(), &["update-index", "--add", "--cacheinfo", mode, id.trim(), rel], None, env)?;
Ok(())
}
pub(crate) fn stage_hunks(
r: &Repo,
path: &Path,
current: &str,
selected: &[usize],
encoding: &'static Encoding,
) -> IdeResult<()> {
if selected.is_empty() {
return Ok(());
}
let rel = r.rel_path(path)?;
let base = r.file_bytes_at(path, Some(":0"))?.unwrap_or_default();
if is_binary(&base, encoding) {
return Err(IdeError::invalid("binary files cannot be staged partially"));
}
let old = decode(&base, encoding);
let hunks = crate::diff::line_hunks(&old, current);
if selected.iter().any(|&i| i >= hunks.len()) {
return Err(IdeError::invalid("hunk no longer exists"));
}
let text = crate::diff::selected_text(&old, current, selected);
let bytes = encode(&text, encoding, has_bom(&base, encoding))?;
write_index_blob(r, &rel, &bytes, &[])
}
// Git headers contain UTF-8 paths; only source lines use the file's encoding.
fn decode_diff(bytes: &[u8], encoding: &'static Encoding) -> String {
let mut hunks = false;
let mut text = String::new();
for line in bytes.split_inclusive(|&b| b == b'\n') {
if line.starts_with(b"diff --git ") {
hunks = false;
}
if line.starts_with(b"@@ ") {
hunks = true;
}
if hunks {
text.push_str(&decode(line, encoding));
} else {
text.push_str(&String::from_utf8_lossy(line));
}
}
text
}
+4 -4
Переглянути файл
@@ -651,7 +651,7 @@ fn rollback_hunk_via_partial_patch_leaves_other_hunks() {
assert_eq!(hunks.len(), 2);
let patch = build_partial_patch("f.txt", &original, &edited, &[0]);
let repo = fx.repo();
repo.stage_patch(&patch).unwrap();
repo.stage_patch(&patch, &|_| Ok(encoding_rs::UTF_8)).unwrap();
repo.discard(&[fx.path("f.txt")]).unwrap();
assert_eq!(fx.read("f.txt"), original.replace("line 2\n", "line two\n"));
}
@@ -777,7 +777,7 @@ fn add_to_vcs_annotate_and_history() {
repo.stage(&[fx.path("new/b.txt")]).unwrap();
assert_eq!(status_of(&repo.status().unwrap(), "new/b.txt").unwrap().staged, Some(ChangeKind::Added));
let blame = repo.blame(&fx.path("a.txt"), Some("1\nchanged\nunsaved\n")).unwrap();
let blame = repo.blame(&fx.path("a.txt"), Some("1\nchanged\nunsaved\n"), encoding_rs::UTF_8).unwrap();
assert_eq!(blame.len(), 3);
assert_eq!(blame[0].commit, first);
assert_eq!(blame[1].commit, second);
@@ -785,8 +785,8 @@ fn add_to_vcs_annotate_and_history() {
let history = repo.log(&LogQuery { path: Some(fx.path("a.txt").into()), ..Default::default() }).unwrap();
assert_eq!(history.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![second.as_str(), first.as_str()]);
assert_eq!(repo.file_at(&fx.path("a.txt"), Some(&first)).unwrap().as_deref(), Some("1\n2\n"));
let diff = repo.diff_text(&fx.path("new/b.txt"), true).unwrap();
assert_eq!(repo.file_at(&fx.path("a.txt"), Some(&first), encoding_rs::UTF_8).unwrap().as_deref(), Some("1\n2\n"));
let diff = repo.diff_text(&fx.path("new/b.txt"), true, encoding_rs::UTF_8).unwrap();
assert!(diff.contains("+b"), "{diff}");
}
+4 -4
Переглянути файл
@@ -113,7 +113,7 @@ fn merge_versions_put_the_users_work_on_the_left() {
let repo = fx.repo();
let topic = fx.git(&["rev-parse", "topic"]);
assert!(repo.rebase("main").is_err());
let v = repo.merge_versions(&fx.path("f.txt")).unwrap();
let v = repo.merge_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
assert_eq!(v.ours.as_deref(), Some("topic\n"));
assert_eq!(v.theirs.as_deref(), Some("main\n"));
assert_eq!(v.base.as_deref(), Some("base\n"));
@@ -129,7 +129,7 @@ fn merge_versions_put_the_users_work_on_the_left() {
// Merge: not swapped; the merged branch is named.
assert!(repo.merge("main").is_err());
let v = repo.merge_versions(&fx.path("f.txt")).unwrap();
let v = repo.merge_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
assert_eq!(v.ours.as_deref(), Some("topic\n"));
assert_eq!(v.theirs.as_deref(), Some("main\n"));
assert!(!v.sides.reversed);
@@ -140,7 +140,7 @@ fn merge_versions_put_the_users_work_on_the_left() {
// Cherry-pick: not swapped; the picked commit is named.
let main = fx.git(&["rev-parse", "main"]);
assert!(repo.cherry_pick(std::slice::from_ref(&main), &cancel, &out).is_err());
let v = repo.merge_versions(&fx.path("f.txt")).unwrap();
let v = repo.merge_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
assert_eq!(v.ours.as_deref(), Some("topic\n"));
assert!(!v.sides.reversed);
assert_eq!(v.sides.operation.as_deref(), Some("cherry-pick"));
@@ -374,7 +374,7 @@ fn unshelve_with_conflict_leaves_merge_state() {
assert_eq!(outcome.conflicts, vec!["f.txt".to_string()]);
let text = fx.read("f.txt");
assert!(text.contains("<<<<<<<") && text.contains("SHELVED") && text.contains("COMMITTED"), "{text}");
let (base, ours, theirs) = repo.conflict_versions(&fx.path("f.txt")).unwrap();
let (base, ours, theirs) = repo.conflict_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
assert!(base.is_some() && ours.unwrap().contains("COMMITTED") && theirs.unwrap().contains("SHELVED"));
// Kept on the shelf when not removing.
assert_eq!(shelf::list(&shelf).unwrap().len(), 1);
+209 -48
Переглянути файл
@@ -121,11 +121,11 @@ fn repository_without_commits() {
assert!(repo.log(&LogQuery::default()).unwrap().is_empty());
assert!(repo.branches().unwrap().is_empty());
assert!(repo.stashes().unwrap().is_empty());
assert_eq!(repo.file_at(Path::new("new.txt"), None).unwrap(), None);
assert_eq!(repo.file_at(Path::new("new.txt"), None, encoding_rs::UTF_8).unwrap(), None);
let st = repo.status().unwrap();
assert_eq!(status_of(&st, "new.txt").unstaged, Some(ChangeKind::Untracked));
let hunks = repo.line_hunks(&fx.path("new.txt"), "hello\n", false).unwrap();
let hunks = repo.line_hunks(&fx.path("new.txt"), "hello\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].kind, HunkKind::Added);
@@ -244,7 +244,7 @@ fn merge_conflict_and_versions() {
let st = repo.status().unwrap();
let s = status_of(&st, "c.txt");
assert_eq!((s.staged, s.unstaged), (None, Some(ChangeKind::Conflicted)));
let (base, ours, theirs) = repo.conflict_versions(&fx.path("c.txt")).unwrap();
let (base, ours, theirs) = repo.conflict_versions(&fx.path("c.txt"), encoding_rs::UTF_8).unwrap();
assert_eq!(base.as_deref(), Some("base\n"));
assert_eq!(ours.as_deref(), Some("ours\n"));
assert_eq!(theirs.as_deref(), Some("theirs\n"));
@@ -378,36 +378,36 @@ fn line_hunks_against_head_and_index() {
let repo = fx.repo();
let p = fx.path("f.txt");
assert!(repo.line_hunks(&p, "1\n2\n3\n4\n5\n", false).unwrap().is_empty());
assert!(repo.line_hunks(&p, "1\r\n2\r\n3\r\n4\r\n5\r\n", false).unwrap().is_empty());
assert!(repo.line_hunks(&p, "1\n2\n3\n4\n5\n", false, encoding_rs::UTF_8).unwrap().is_empty());
assert!(repo.line_hunks(&p, "1\r\n2\r\n3\r\n4\r\n5\r\n", false, encoding_rs::UTF_8).unwrap().is_empty());
let h = repo.line_hunks(&p, "0\n1\n2\n3\n4\n5\n", false).unwrap();
let h = repo.line_hunks(&p, "0\n1\n2\n3\n4\n5\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!((h.len(), h[0].kind, h[0].start, h[0].lines), (1, HunkKind::Added, 0, 1));
let h = repo.line_hunks(&p, "1\n2\n3\n4\n5\n6\n", false).unwrap();
let h = repo.line_hunks(&p, "1\n2\n3\n4\n5\n6\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!((h[0].kind, h[0].start, h[0].lines), (HunkKind::Added, 5, 1));
let h = repo.line_hunks(&p, "1\nX\n3\n4\n5\n", false).unwrap();
let h = repo.line_hunks(&p, "1\nX\n3\n4\n5\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!((h[0].kind, h[0].start, h[0].lines, h[0].old_text.as_str()), (HunkKind::Modified, 1, 1, "2\n"));
let h = repo.line_hunks(&p, "1\n4\n5\n", false).unwrap();
let h = repo.line_hunks(&p, "1\n4\n5\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!((h[0].kind, h[0].start, h[0].lines, h[0].old_start, h[0].old_lines), (HunkKind::Deleted, 1, 0, 1, 2));
let h = repo.line_hunks(&p, "2\n3\n4\n5\n", false).unwrap();
let h = repo.line_hunks(&p, "2\n3\n4\n5\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!((h[0].kind, h[0].start), (HunkKind::Deleted, 0));
let h = repo.line_hunks(&p, "1\n2\n3\n4\n", false).unwrap();
let h = repo.line_hunks(&p, "1\n2\n3\n4\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!((h[0].kind, h[0].start, h[0].old_start), (HunkKind::Deleted, 4, 4));
// Against the index: staged changes are not reported.
fx.write("f.txt", "1\nX\n3\n4\n5\n");
fx.git(&["add", "f.txt"]);
assert!(repo.line_hunks(&p, "1\nX\n3\n4\n5\n", true).unwrap().is_empty());
assert_eq!(repo.line_hunks(&p, "1\nX\n3\n4\n5\n", false).unwrap().len(), 1);
assert!(repo.line_hunks(&p, "1\nX\n3\n4\n5\n", true, encoding_rs::UTF_8).unwrap().is_empty());
assert_eq!(repo.line_hunks(&p, "1\nX\n3\n4\n5\n", false, encoding_rs::UTF_8).unwrap().len(), 1);
// New and ignored files.
let h = repo.line_hunks(&fx.path("new.txt"), "a\nb\n", false).unwrap();
let h = repo.line_hunks(&fx.path("new.txt"), "a\nb\n", false, encoding_rs::UTF_8).unwrap();
assert_eq!((h.len(), h[0].kind, h[0].start, h[0].lines), (1, HunkKind::Added, 0, 2));
assert!(repo.line_hunks(&fx.path("ignored.txt"), "a\n", false).unwrap().is_empty());
assert!(repo.line_hunks(Path::new("/definitely/outside"), "a\n", false).unwrap().is_empty());
assert!(repo.line_hunks(&fx.path("ignored.txt"), "a\n", false, encoding_rs::UTF_8).unwrap().is_empty());
assert!(repo.line_hunks(Path::new("/definitely/outside"), "a\n", false, encoding_rs::UTF_8).unwrap().is_empty());
}
#[test]
@@ -419,7 +419,7 @@ fn blame_with_unsaved_edits() {
let second = fx.commit_all("second");
let repo = fx.repo();
let lines = repo.blame(&fx.path("b.txt"), None).unwrap();
let lines = repo.blame(&fx.path("b.txt"), None, encoding_rs::UTF_8).unwrap();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].commit, first);
assert_eq!(lines[1].commit, second);
@@ -427,7 +427,7 @@ fn blame_with_unsaved_edits() {
assert_eq!(lines[0].author, "Test User");
assert!(lines[0].time_ms > 1.0e12);
let lines = repo.blame(&fx.path("b.txt"), Some("one\nedited\nthree\nfour")).unwrap();
let lines = repo.blame(&fx.path("b.txt"), Some("one\nedited\nthree\nfour"), encoding_rs::UTF_8).unwrap();
assert_eq!(lines.len(), 4);
assert_eq!(lines[0].commit, first);
assert!(lines[1].commit.bytes().all(|b| b == b'0'));
@@ -435,7 +435,7 @@ fn blame_with_unsaved_edits() {
assert_eq!(lines[2].commit, first);
assert_eq!(lines[3].author, "Not Committed Yet");
let lines = repo.blame(&fx.path("untracked.txt"), Some("x\ny\n")).unwrap();
let lines = repo.blame(&fx.path("untracked.txt"), Some("x\ny\n"), encoding_rs::UTF_8).unwrap();
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|l| l.author == "Not Committed Yet"));
}
@@ -615,12 +615,12 @@ fn file_at_revisions_and_index() {
fx.git(&["add", "f.txt"]);
let repo = fx.repo();
let p = fx.path("f.txt");
assert_eq!(repo.file_at(&p, None).unwrap().as_deref(), Some("v2\n"));
assert_eq!(repo.file_at(&p, Some(&c1)).unwrap().as_deref(), Some("v1\n"));
assert_eq!(repo.file_at(&p, Some("HEAD~1")).unwrap().as_deref(), Some("v1\n"));
assert_eq!(repo.file_at(&p, Some(":0")).unwrap().as_deref(), Some("v3\n"));
assert_eq!(repo.file_at(Path::new("missing.txt"), None).unwrap(), None);
assert_eq!(repo.file_at(&p, Some("no-such-rev")).unwrap_err().kind, ErrorKind::NotFound);
assert_eq!(repo.file_at(&p, None, encoding_rs::UTF_8).unwrap().as_deref(), Some("v2\n"));
assert_eq!(repo.file_at(&p, Some(&c1), encoding_rs::UTF_8).unwrap().as_deref(), Some("v1\n"));
assert_eq!(repo.file_at(&p, Some("HEAD~1"), encoding_rs::UTF_8).unwrap().as_deref(), Some("v1\n"));
assert_eq!(repo.file_at(&p, Some(":0"), encoding_rs::UTF_8).unwrap().as_deref(), Some("v3\n"));
assert_eq!(repo.file_at(Path::new("missing.txt"), None, encoding_rs::UTF_8).unwrap(), None);
assert_eq!(repo.file_at(&p, Some("no-such-rev"), encoding_rs::UTF_8).unwrap_err().kind, ErrorKind::NotFound);
}
#[test]
@@ -636,7 +636,7 @@ fn file_at_in_a_blobless_clone() {
git_in(fx.dir.parent().unwrap(), &["clone", "-q", "--filter=blob:none", &url, "clone"]);
let repo = Repo::discover(&clone).unwrap().unwrap();
// v1's blob was never fetched; reading it fetches it on demand.
assert_eq!(repo.file_at(&clone.join("f.txt"), Some(&c1)).unwrap().as_deref(), Some("v1\n"));
assert_eq!(repo.file_at(&clone.join("f.txt"), Some(&c1), encoding_rs::UTF_8).unwrap().as_deref(), Some("v1\n"));
}
#[test]
@@ -850,9 +850,12 @@ fn commit_with_author_and_without_hooks() {
fx.write("a", "2\n");
repo.stage(&[fx.path("a")]).unwrap();
let hooked = CommitOptions { run_hooks: true, ..Default::default() };
assert!(repo.commit_with("blocked", &hooked).is_err(), "the failing pre-commit hook runs");
assert!(
repo.commit_with("blocked", &hooked, &|_| Ok(encoding_rs::UTF_8)).is_err(),
"the failing pre-commit hook runs"
);
let options = CommitOptions { author: Some(" Other <[email protected]> ".into()), ..Default::default() };
repo.commit_with("by other", &options).unwrap();
repo.commit_with("by other", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap();
assert_eq!(fx.git(&["log", "-1", "--format=%an <%ae>"]), "Other <[email protected]>");
}
@@ -932,7 +935,7 @@ fn smart_and_force_checkout_with_local_changes_in_the_way() {
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("other"));
assert_eq!(repo.stashes().unwrap().len(), 1, "git keeps the stash after a conflicting pop");
// The stashed edit is the user's side of the merge, the checked-out branch the other.
let v = repo.merge_versions(&fx.path("f.txt")).unwrap();
let v = repo.merge_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
assert_eq!(v.sides.operation.as_deref(), Some("unstash"));
assert!(v.sides.reversed);
assert_eq!(v.ours.as_deref(), Some("1\nmine\n3\n4\n5\n"));
@@ -1049,7 +1052,7 @@ fn partial_commit_of_chosen_hunks_leaves_worktree_and_index_alone() {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
..Default::default()
};
repo.commit_with("partial", &options).unwrap();
repo.commit_with("partial", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap();
assert_eq!(fx.git(&["show", "HEAD:f.txt"]), "A\nb\nc\nd\ne\nf\ng\nh\ni\nj");
assert_eq!(fx.git(&["show", "HEAD:other.txt"]), "o2");
@@ -1070,21 +1073,21 @@ fn partial_commit_of_chosen_hunks_leaves_worktree_and_index_alone() {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
..Default::default()
};
let err = repo.commit_with("stale", &stale).unwrap_err();
let err = repo.commit_with("stale", &stale, &|_| Ok(encoding_rs::UTF_8)).unwrap_err();
assert!(err.message.contains("changed"), "{}", err.message);
assert_eq!(fx.git(&["rev-parse", "HEAD"]), before);
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "");
assert!(!fx.dir.join(".git/openidea-partial-index").exists());
// The remaining hunk commits on its own; then nothing is left.
let rest = diff_hunks(&repo.file_at(&fx.path("f.txt"), None).unwrap().unwrap(), edited);
let rest = diff_hunks(&repo.file_at(&fx.path("f.txt"), None, encoding_rs::UTF_8).unwrap().unwrap(), edited);
assert_eq!(rest.len(), 1);
let options = CommitOptions {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&rest[0])] }],
..Default::default()
};
repo.commit_with("rest", &options).unwrap();
assert_eq!(repo.file_at(&fx.path("f.txt"), None).unwrap().as_deref(), Some(edited));
repo.commit_with("rest", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap();
assert_eq!(repo.file_at(&fx.path("f.txt"), None, encoding_rs::UTF_8).unwrap().as_deref(), Some(edited));
assert!(repo.status().unwrap().iter().all(|f| f.rel_path != "f.txt"));
assert_eq!(fx.read("staged.txt"), "s2\n");
@@ -1105,7 +1108,7 @@ fn partial_commit_of_chosen_hunks_leaves_worktree_and_index_alone() {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
..Default::default()
};
assert!(repo.commit_with("hooked", &options).is_err());
assert!(repo.commit_with("hooked", &options, &|_| Ok(encoding_rs::UTF_8)).is_err());
assert_eq!(fx.git(&["rev-parse", "HEAD"]), head);
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "");
assert_eq!(fx.read("f.txt"), "Z\nb\nc\nd\ne\nf\ng\nh\ni\nJ\nk\nl\n");
@@ -1121,13 +1124,13 @@ fn partial_staging_with_built_patch() {
let new = "l0\nl1\nl2\nCHANGED\nl4\nl5\nl6\nl8\nl9\nl10\n";
fx.write("p.txt", new);
let index_text = repo.file_at(&fx.path("p.txt"), Some(":0")).unwrap().unwrap();
let index_text = repo.file_at(&fx.path("p.txt"), Some(":0"), encoding_rs::UTF_8).unwrap().unwrap();
let hunks = ide_vcs::diff_hunks(&index_text, new);
assert_eq!(hunks.len(), 4, "{hunks:#?}");
// Stage the modification and the trailing addition only.
let patch = build_partial_patch("p.txt", &index_text, new, &[1, 3]);
repo.stage_patch(&patch).unwrap();
let staged = repo.file_at(&fx.path("p.txt"), Some(":0")).unwrap().unwrap();
repo.stage_patch(&patch, &|_| Ok(encoding_rs::UTF_8)).unwrap();
let staged = repo.file_at(&fx.path("p.txt"), Some(":0"), encoding_rs::UTF_8).unwrap().unwrap();
assert_eq!(staged, "l1\nl2\nCHANGED\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n");
let st = repo.status().unwrap();
let s = status_of(&st, "p.txt");
@@ -1135,21 +1138,21 @@ fn partial_staging_with_built_patch() {
// Then the rest, one hunk at a time against the updated index.
loop {
let index_text = repo.file_at(&fx.path("p.txt"), Some(":0")).unwrap().unwrap();
let index_text = repo.file_at(&fx.path("p.txt"), Some(":0"), encoding_rs::UTF_8).unwrap().unwrap();
if ide_vcs::diff_hunks(&index_text, new).is_empty() {
break;
}
repo.stage_patch(&build_partial_patch("p.txt", &index_text, new, &[0])).unwrap();
repo.stage_patch(&build_partial_patch("p.txt", &index_text, new, &[0]), &|_| Ok(encoding_rs::UTF_8)).unwrap();
}
assert_eq!(repo.file_at(&fx.path("p.txt"), Some(":0")).unwrap().unwrap(), new);
assert_eq!(repo.file_at(&fx.path("p.txt"), Some(":0"), encoding_rs::UTF_8).unwrap().unwrap(), new);
// Patches also apply to files without a trailing newline and CRLF files.
fx.write("n.txt", "a\r\nb\r\nc");
fx.commit_all("crlf");
let new = "a\nB\nc\nd";
let old = repo.file_at(&fx.path("n.txt"), Some(":0")).unwrap().unwrap();
repo.stage_patch(&build_partial_patch("n.txt", &old, new, &[0, 1])).unwrap();
assert_eq!(repo.file_at(&fx.path("n.txt"), Some(":0")).unwrap().unwrap(), "a\r\nB\r\nc\r\nd");
let old = repo.file_at(&fx.path("n.txt"), Some(":0"), encoding_rs::UTF_8).unwrap().unwrap();
repo.stage_patch(&build_partial_patch("n.txt", &old, new, &[0, 1]), &|_| Ok(encoding_rs::UTF_8)).unwrap();
assert_eq!(repo.file_at(&fx.path("n.txt"), Some(":0"), encoding_rs::UTF_8).unwrap().unwrap(), "a\r\nB\r\nc\r\nd");
}
#[test]
@@ -1170,13 +1173,13 @@ fn discard_and_diff_text() {
fx.write("keep/new.txt", "n\n");
fx.write("keep/k.txt", "changed\n");
let unstaged = repo.diff_text(&fx.path("both.txt"), false).unwrap();
let unstaged = repo.diff_text(&fx.path("both.txt"), false, encoding_rs::UTF_8).unwrap();
assert!(unstaged.contains("-staged\n+unstaged"), "{unstaged}");
let staged = repo.diff_text(&fx.path("both.txt"), true).unwrap();
let staged = repo.diff_text(&fx.path("both.txt"), true, encoding_rs::UTF_8).unwrap();
assert!(staged.contains("-one\n+staged"), "{staged}");
let untracked = repo.diff_text(&fx.path("u.txt"), false).unwrap();
let untracked = repo.diff_text(&fx.path("u.txt"), false, encoding_rs::UTF_8).unwrap();
assert!(untracked.contains("+untracked"), "{untracked}");
assert!(repo.diff_text(&fx.path("t.txt"), true).unwrap().is_empty());
assert!(repo.diff_text(&fx.path("t.txt"), true, encoding_rs::UTF_8).unwrap().is_empty());
repo.discard(&[fx.path("t.txt"), fx.path("both.txt"), fx.path("u.txt"), fx.path("udir"), fx.path("keep")]).unwrap();
assert_eq!(fx.read("t.txt"), "one\n");
@@ -1322,3 +1325,161 @@ fn path_log_simplifies_merges() {
.collect();
assert_eq!(ids, vec![base]);
}
#[test]
fn gbk_java_unchanged_has_no_hunks() {
let fx = Fixture::new();
let text = "// 中文注释\nclass Example {\n // 保持不变\n}\n";
std::fs::write(fx.path("Example.java"), encoding_rs::GBK.encode(text).0).unwrap();
fx.commit_all("GBK Java");
for against_index in [false, true] {
assert!(
fx.repo().line_hunks(&fx.path("Example.java"), text, against_index, encoding_rs::GBK).unwrap().is_empty()
);
}
}
#[test]
fn encoded_java_hunks_staging_blame_and_partial_commit_preserve_bytes() {
for encoding in [encoding_rs::GBK, encoding_rs::SHIFT_JIS, encoding_rs::UTF_8] {
for eol in ["\n", "\r\n"] {
let fx = Fixture::new();
let path = fx.path("中文.java");
let old = "// 中文注釈\nclass Example {\n // 保存\n int value = 1;\n}\n".replace('\n', eol);
let changed = old.replace("中文注釈", "中文変更").replace("value = 1", "value = 2");
let bytes = |text: &str| {
let (bytes, _, errors) = encoding.encode(text);
assert!(!errors);
bytes.into_owned()
};
std::fs::write(&path, bytes(&old)).unwrap();
fx.commit_all("encoded Java");
let repo = fx.repo();
assert_eq!(repo.file_at(&path, None, encoding).unwrap().as_deref(), Some(old.as_str()));
assert!(
repo.blame(&path, Some(&old.replace("\r\n", "\n")), encoding)
.unwrap()
.iter()
.all(|line| line.author == "Test User")
);
let hunks = repo.line_hunks(&path, &old.replace("value = 1", "value = 2"), false, encoding).unwrap();
assert_eq!(hunks.len(), 1);
assert_eq!((hunks[0].start, hunks[0].lines, hunks[0].kind), (3, 1, HunkKind::Modified));
std::fs::write(&path, bytes(&changed)).unwrap();
let hunks = repo.line_hunks(&path, &changed, false, encoding).unwrap();
assert_eq!(hunks.len(), 2);
repo.stage_hunks(&path, &changed, &[0], encoding).unwrap();
let selected = old.replace("中文注釈", "中文変更");
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), bytes(&selected));
assert_eq!(std::fs::read(&path).unwrap(), bytes(&changed));
assert!(repo.diff_text(&path, true, encoding).unwrap().contains("中文変更"));
repo.unstage(std::slice::from_ref(&path)).unwrap();
let options = CommitOptions {
partial: vec![PartialCommitFile {
path: path.clone().into(),
hunks: vec![HunkRange {
start: hunks[0].start,
lines: hunks[0].lines,
old_start: hunks[0].old_start,
old_lines: hunks[0].old_lines,
}],
}],
..Default::default()
};
repo.commit_with("selected hunk", &options, &|_| Ok(encoding)).unwrap();
assert_eq!(repo.file_bytes_at(&path, None).unwrap().unwrap(), bytes(&selected));
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), bytes(&selected));
assert_eq!(std::fs::read(&path).unwrap(), bytes(&changed));
}
}
}
#[test]
fn gbk_patches_round_trip_even_with_text_attributes() {
let fx = Fixture::new();
let path = fx.path("Example.java");
let old = encoding_rs::GBK.encode("// 中文注释\nclass Example {}\n").0.into_owned();
let new = encoding_rs::GBK.encode("// 修改注释\nclass Example {}\n").0.into_owned();
fx.write(".gitattributes", "*.java diff\n");
std::fs::write(&path, &old).unwrap();
fx.commit_all("base");
std::fs::write(&path, &new).unwrap();
let repo = fx.repo();
let patch = repo.create_patch(std::slice::from_ref(&path), false).unwrap();
assert!(patch.contains("GIT binary patch"));
repo.discard(std::slice::from_ref(&path)).unwrap();
repo.apply_patch(&patch).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), new);
let commit = fx.commit_all("change");
let reverse = repo.create_commits_patch(&[commit], true).unwrap();
repo.apply_patch(&reverse).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), old);
}
#[test]
fn gbk_conflict_stages_are_readable() {
let fx = Fixture::new();
let path = fx.path("Example.java");
let write = |text: &str| std::fs::write(&path, encoding_rs::GBK.encode(text).0).unwrap();
write("// 原始注释\n");
fx.commit_all("base");
fx.git(&["checkout", "-b", "other"]);
write("// 分支注释\n");
fx.commit_all("other");
fx.git(&["checkout", "main"]);
write("// 当前注释\n");
fx.commit_all("ours");
let out = Command::new("git").current_dir(&fx.dir).args(["merge", "other"]).output().unwrap();
assert!(!out.status.success());
let versions = fx.repo().merge_versions(&path, encoding_rs::GBK).unwrap();
assert_eq!(versions.base.as_deref(), Some("// 原始注释\n"));
assert_eq!(versions.ours.as_deref(), Some("// 当前注释\n"));
assert_eq!(versions.theirs.as_deref(), Some("// 分支注释\n"));
}
#[test]
fn utf16_and_utf8_boms_survive_partial_staging() {
for encoding in [encoding_rs::UTF_8, encoding_rs::UTF_16LE, encoding_rs::UTF_16BE] {
let fx = Fixture::new();
let path = fx.path("Example.java");
let old = "// 中文注释\r\nclass Example {}\r\n";
let changed = old.replace("中文", "修改");
let bytes = |text: &str| {
if encoding == encoding_rs::UTF_8 {
let mut bytes = vec![0xef, 0xbb, 0xbf];
bytes.extend_from_slice(text.as_bytes());
bytes
} else {
let le = encoding == encoding_rs::UTF_16LE;
let mut bytes = if le { vec![0xff, 0xfe] } else { vec![0xfe, 0xff] };
for unit in text.encode_utf16() {
bytes.extend_from_slice(&if le { unit.to_le_bytes() } else { unit.to_be_bytes() });
}
bytes
}
};
std::fs::write(&path, bytes(old)).unwrap();
fx.commit_all("BOM");
let repo = fx.repo();
assert!(repo.line_hunks(&path, old, false, encoding).unwrap().is_empty());
repo.stage_hunks(&path, &changed, &[0], encoding).unwrap();
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), bytes(&changed));
}
}
#[test]
fn gbk_text_patch_keeps_utf8_paths_and_encoded_payload() {
let fx = Fixture::new();
let path = fx.path("中文.java");
let old = "// 中文注释\n";
let new = "// 修改注释\n";
std::fs::write(&path, encoding_rs::GBK.encode(old).0).unwrap();
fx.commit_all("GBK");
let repo = fx.repo();
let patch = build_partial_patch("中文.java", old, new, &[0]);
repo.stage_patch(&patch, &|_| Ok(encoding_rs::GBK)).unwrap();
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), encoding_rs::GBK.encode(new).0.as_ref());
let patch = build_partial_patch("中文.java", new, "// 😀\n", &[0]);
assert!(repo.stage_patch(&patch, &|_| Ok(encoding_rs::GBK)).is_err());
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), encoding_rs::GBK.encode(new).0.as_ref());
}
+15
Переглянути файл
@@ -313,6 +313,21 @@ impl FsBatch {
}
}
/// Detect a revision's encoding with the same rules as files opened in the editor.
pub fn detect_encoding(bytes: &[u8], fallback: Option<&str>) -> &'static encoding_rs::Encoding {
let (_, detected, has_bom, binary) = encoding::decode(bytes);
if !binary
&& !has_bom
&& detected != encoding_rs::UTF_8
&& detected != encoding_rs::UTF_16LE
&& detected != encoding_rs::UTF_16BE
{
fallback.and_then(|label| encoding_rs::Encoding::for_label(label.trim().as_bytes())).unwrap_or(detected)
} else {
detected
}
}
#[cfg(test)]
mod tests {
use super::*;