mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 00:07:29 +00:00
Properties documents passed through charset decoding alone, leaving Unicode escapes in the shared document text. - Add a default-off "Transparent native-to-ascii conversion" setting under the properties encoding and a stable action. Changing it reloads clean properties buffers in place; dirty buffers keep their codec. - Decode eligible escapes and surrogate pairs when constructing documents, and save non-ASCII as uppercase escapes in pure ASCII. Backslash pairs, malformed escapes, unpaired surrogates and line terminator escapes stay literal, so an edit cannot split a property across lines. - Use the same codec for search, closed-file readers, Git diffs, staging and partial commits. Clean saves and undone edits preserve the original bytes. Fixes #519
464 lines
18 KiB
Rust
464 lines
18 KiB
Rust
//! Unified-diff patches: creating them from local changes or commits, previewing
|
|
//! and applying them (falling back to a 3-way merge that leaves conflicts for the
|
|
//! merge UI), as Create Patch / Apply Patch / Shelve do in IDEA.
|
|
|
|
use crate::cli::{self, run, run_env};
|
|
use crate::repo::gx;
|
|
use crate::{ApplyOutcome, ChangeKind, ChangedFile, PatchFile, Repo};
|
|
use ide_core::{IdeError, IdeResult};
|
|
use std::ffi::OsStr;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
|
|
/// A throwaway index file in `.git`, removed on drop.
|
|
struct TempIndex(PathBuf);
|
|
|
|
impl TempIndex {
|
|
fn new(r: &Repo) -> Self {
|
|
static SEQ: AtomicU32 = AtomicU32::new(0);
|
|
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
TempIndex(r.git_dir.join(format!("openidea-index-{}-{n}", std::process::id())))
|
|
}
|
|
fn env(&self) -> [(&'static str, &OsStr); 1] {
|
|
[("GIT_INDEX_FILE", self.0.as_os_str())]
|
|
}
|
|
}
|
|
|
|
impl Drop for TempIndex {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_file(&self.0);
|
|
let _ = std::fs::remove_file(self.0.with_extension("lock"));
|
|
}
|
|
}
|
|
|
|
/// Binary-safe patch of the working-tree changes (staged or not, untracked files
|
|
/// included) of `rel_paths` against HEAD, plus the files it touches (renames split
|
|
/// into delete + add). The real index is left untouched.
|
|
pub(crate) fn working_patch(
|
|
r: &Repo,
|
|
rel_paths: &[String],
|
|
reverse: bool,
|
|
detect_renames: bool,
|
|
) -> IdeResult<(String, Vec<ChangedFile>)> {
|
|
if rel_paths.is_empty() {
|
|
return Err(IdeError::invalid("no files selected"));
|
|
}
|
|
let tmp = TempIndex::new(r);
|
|
let env = tmp.env();
|
|
if r.has_head()? {
|
|
run_env(r.root(), &["read-tree", "HEAD"], None, &env)?;
|
|
} else {
|
|
run_env(r.root(), &["read-tree", "--empty"], None, &env)?;
|
|
}
|
|
cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], rel_paths, None, &env)?;
|
|
let mut diff = vec![
|
|
"diff",
|
|
"--cached",
|
|
"--binary",
|
|
"--full-index",
|
|
if detect_renames { "-M" } else { "--no-renames" },
|
|
"--no-color",
|
|
"--no-ext-diff",
|
|
"--no-textconv",
|
|
];
|
|
if reverse {
|
|
diff.push("-R");
|
|
}
|
|
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())))
|
|
}
|
|
|
|
/// The changes of `commits` (oldest first), each against its first parent.
|
|
pub(crate) fn commits_patch(r: &Repo, commits: &[String], reverse: bool) -> IdeResult<String> {
|
|
if commits.is_empty() {
|
|
return Err(IdeError::invalid("no commits selected"));
|
|
}
|
|
let mut out = String::new();
|
|
let ordered: Vec<&String> = if reverse { commits.iter().rev().collect() } else { commits.iter().collect() };
|
|
for c in ordered {
|
|
let (parent, id) = {
|
|
let repo = r.gix()?;
|
|
let id = crate::repo::resolve(&repo, c)?;
|
|
let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?;
|
|
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", "--no-textconv"];
|
|
if reverse {
|
|
args.push("-R");
|
|
}
|
|
args.extend([parent.as_str(), id.as_str(), "--"]);
|
|
out.push_str(&lossless_diff(r, &args, &[])?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn normalized(patch: &str) -> IdeResult<String> {
|
|
if patch.trim().is_empty() {
|
|
return Err(IdeError::invalid("the patch is empty"));
|
|
}
|
|
let mut p = patch.replace("\r\n", "\n");
|
|
if !p.ends_with('\n') {
|
|
p.push('\n');
|
|
}
|
|
Ok(p)
|
|
}
|
|
|
|
/// The files of a patch, whether each applies cleanly to the working tree.
|
|
pub(crate) fn preview(r: &Repo, patch: &str) -> IdeResult<Vec<PatchFile>> {
|
|
let patch = normalized(patch)?;
|
|
let mut files = parse_patch(&patch);
|
|
if files.is_empty() {
|
|
return Err(IdeError::invalid("no file changes found in the patch"));
|
|
}
|
|
let out = cli::run_raw(r.root(), &["apply", "--check", "--binary", "-"], Some(patch.as_bytes()))?;
|
|
if out.code != Some(0) {
|
|
let err = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
let failing = failing_paths(&err);
|
|
for f in &mut files {
|
|
let hit = |p: &str| failing.iter().any(|x| x == p);
|
|
f.applies = !(hit(&f.path) || f.old_path.as_deref().is_some_and(hit));
|
|
}
|
|
// An error we could not attribute to a file: nothing applies cleanly.
|
|
if files.iter().all(|f| f.applies) {
|
|
files.iter_mut().for_each(|f| f.applies = false);
|
|
}
|
|
}
|
|
Ok(files)
|
|
}
|
|
|
|
/// Paths named in `git apply` errors (`error: path: …`, `error: patch failed: path:12`).
|
|
fn failing_paths(stderr: &str) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
for line in stderr.lines() {
|
|
let Some(rest) = line.strip_prefix("error: ") else { continue };
|
|
if let Some(p) = rest.strip_prefix("patch failed: ") {
|
|
if let Some((path, _line)) = p.rsplit_once(':') {
|
|
out.push(path.to_string());
|
|
}
|
|
} else if let Some((path, _)) = rest.split_once(": ") {
|
|
out.push(path.to_string());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Applies a patch to the working tree. When it does not apply cleanly, a 3-way
|
|
/// merge is attempted; conflicting files are left with conflict markers and index
|
|
/// stages for the merge UI and returned in [`ApplyOutcome::conflicts`].
|
|
pub(crate) fn apply(r: &Repo, patch: &str) -> IdeResult<ApplyOutcome> {
|
|
let patch = normalized(patch)?;
|
|
let files = parse_patch(&patch);
|
|
let paths: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
|
|
let check = cli::run_raw(r.root(), &["apply", "--check", "--binary", "-"], Some(patch.as_bytes()))?;
|
|
if check.code == Some(0) {
|
|
run(r.root(), &["apply", "--binary", "-"], Some(patch.as_bytes()))?;
|
|
return Ok(ApplyOutcome { applied: paths, conflicts: Vec::new() });
|
|
}
|
|
let out = cli::run_raw(r.root(), &["apply", "--3way", "--binary", "-"], Some(patch.as_bytes()))?;
|
|
let conflicts = unmerged(r)?;
|
|
if out.code != Some(0) && conflicts.is_empty() {
|
|
return Err(cli::failure(&["apply"], &out.stderr, &out.stdout, out.code));
|
|
}
|
|
// `--3way` stages what it applied; keep the index as it was for the clean files.
|
|
let clean: Vec<String> = paths.iter().filter(|p| !conflicts.contains(p)).cloned().collect();
|
|
if !clean.is_empty() && r.has_head()? {
|
|
cli::run_paths(r.root(), &["--literal-pathspecs", "reset", "-q", "--"], &clean)?;
|
|
}
|
|
Ok(ApplyOutcome { applied: clean, conflicts })
|
|
}
|
|
|
|
fn unmerged(r: &Repo) -> IdeResult<Vec<String>> {
|
|
let out = run(r.root(), &["diff", "--name-only", "--diff-filter=U", "-z"], None)?;
|
|
Ok(out.stdout_str().split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect())
|
|
}
|
|
|
|
fn git_path(p: &str) -> String {
|
|
let Some(p) = p.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else { return p.to_string() };
|
|
let mut bytes = Vec::new();
|
|
let mut chars = p.bytes().peekable();
|
|
while let Some(c) = chars.next() {
|
|
if c != b'\\' {
|
|
bytes.push(c);
|
|
continue;
|
|
}
|
|
let Some(c) = chars.next() else { break };
|
|
bytes.push(match c {
|
|
b'n' => b'\n',
|
|
b't' => b'\t',
|
|
b'r' => b'\r',
|
|
b'a' => 7,
|
|
b'b' => 8,
|
|
b'v' => 11,
|
|
b'f' => 12,
|
|
b'0'..=b'7' => {
|
|
let mut n = c - b'0';
|
|
for _ in 0..2 {
|
|
if let Some(d @ b'0'..=b'7') = chars.peek().copied() {
|
|
chars.next();
|
|
n = n.wrapping_mul(8).wrapping_add(d - b'0');
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
n
|
|
}
|
|
c => c,
|
|
});
|
|
}
|
|
String::from_utf8_lossy(&bytes).into_owned()
|
|
}
|
|
|
|
fn strip_prefix(p: &str) -> Option<String> {
|
|
let p = if p.starts_with('"') { p } else { p.split('\t').next().unwrap_or(p) };
|
|
if p == "/dev/null" {
|
|
return None;
|
|
}
|
|
let p = git_path(p);
|
|
Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(&p).to_string())
|
|
}
|
|
|
|
/// Files changed by a unified diff (git extended headers or plain `---`/`+++`).
|
|
pub(crate) fn parse_patch(patch: &str) -> Vec<PatchFile> {
|
|
let mut out: Vec<PatchFile> = Vec::new();
|
|
let mut cur: Option<PatchFile> = None;
|
|
// From a `diff --git` header and no hunk seen yet: `---` belongs to it.
|
|
let mut fresh_header = false;
|
|
let (mut old_left, mut new_left) = (0u32, 0u32);
|
|
let new_file = |path: String| PatchFile {
|
|
path,
|
|
old_path: None,
|
|
kind: ChangeKind::Modified,
|
|
added: 0,
|
|
removed: 0,
|
|
binary: false,
|
|
applies: true,
|
|
};
|
|
for line in patch.lines() {
|
|
if old_left > 0 || new_left > 0 {
|
|
let Some(c) = cur.as_mut() else { break };
|
|
match line.as_bytes().first() {
|
|
Some(b'+') => {
|
|
c.added += 1;
|
|
new_left = new_left.saturating_sub(1);
|
|
}
|
|
Some(b'-') => {
|
|
c.removed += 1;
|
|
old_left = old_left.saturating_sub(1);
|
|
}
|
|
Some(b'\\') => {}
|
|
_ => {
|
|
old_left = old_left.saturating_sub(1);
|
|
new_left = new_left.saturating_sub(1);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(rest) = line.strip_prefix("diff --git ") {
|
|
out.extend(cur.take());
|
|
let path = rest
|
|
.rsplit_once(" \"b/")
|
|
.map(|(_, b)| git_path(&format!("\"{b}")))
|
|
.or_else(|| rest.rsplit_once(" b/").map(|(_, b)| b.to_string()))
|
|
.unwrap_or_else(|| rest.to_string());
|
|
let path = path.strip_prefix("b/").unwrap_or(&path).to_string();
|
|
cur = Some(new_file(path));
|
|
fresh_header = true;
|
|
} else if let Some(rest) = line.strip_prefix("--- ") {
|
|
if !fresh_header {
|
|
out.extend(cur.take());
|
|
cur = Some(new_file(String::new()));
|
|
}
|
|
fresh_header = false;
|
|
if let Some(c) = cur.as_mut() {
|
|
match strip_prefix(rest) {
|
|
Some(p) if c.path.is_empty() => c.path = p,
|
|
Some(_) => {}
|
|
None => c.kind = ChangeKind::Added,
|
|
}
|
|
}
|
|
} else if let Some(c) = cur.as_mut() {
|
|
if let Some(rest) = line.strip_prefix("+++ ") {
|
|
match strip_prefix(rest) {
|
|
Some(p) => c.path = p,
|
|
None => c.kind = ChangeKind::Deleted,
|
|
}
|
|
} else if let Some(range) = line.strip_prefix("@@ ") {
|
|
fresh_header = false;
|
|
let count = |spec: &str| -> u32 {
|
|
let spec = &spec[1..];
|
|
match spec.split_once(',') {
|
|
Some((_, n)) => n.parse().unwrap_or(0),
|
|
None => 1,
|
|
}
|
|
};
|
|
let mut parts = range.split_whitespace();
|
|
old_left = parts.next().filter(|p| p.starts_with('-')).map(count).unwrap_or(0);
|
|
new_left = parts.next().filter(|p| p.starts_with('+')).map(count).unwrap_or(0);
|
|
} else if line.starts_with("new file mode") {
|
|
c.kind = ChangeKind::Added;
|
|
} else if line.starts_with("deleted file mode") {
|
|
c.kind = ChangeKind::Deleted;
|
|
} else if let Some(p) = line.strip_prefix("rename from ") {
|
|
c.old_path = Some(git_path(p));
|
|
c.kind = ChangeKind::Renamed;
|
|
} else if let Some(p) = line.strip_prefix("rename to ") {
|
|
c.path = git_path(p);
|
|
} else if line.starts_with("GIT binary patch") || line.starts_with("Binary files ") {
|
|
c.binary = true;
|
|
}
|
|
}
|
|
}
|
|
out.extend(cur);
|
|
out.retain(|f| !f.path.is_empty());
|
|
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<crate::TextEncoding>,
|
|
) -> 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)
|
|
}
|
|
|
|
/// Apply displayed-text line selections in a temporary UTF-8 index, then
|
|
/// serialize the result. Literal escape spelling in the real index must not
|
|
/// prevent a selected patch from matching its decoded base.
|
|
pub(crate) fn stage_converted_patch(
|
|
r: &Repo,
|
|
patch: &str,
|
|
encoding: &dyn Fn(&std::path::Path) -> IdeResult<crate::TextEncoding>,
|
|
) -> IdeResult<()> {
|
|
use crate::text_encoding::{decode, encode, has_bom};
|
|
let tmp = TempIndex::new(r);
|
|
let env = tmp.env();
|
|
let index = r.git_dir.join("index");
|
|
if index.exists() {
|
|
std::fs::copy(index, &tmp.0)?;
|
|
} else {
|
|
run_env(r.root(), &["read-tree", "--empty"], None, &env)?;
|
|
}
|
|
let mut files = Vec::new();
|
|
for file in parse_patch(patch) {
|
|
let path = r.abs_path(&file.path);
|
|
let enc = encoding(&path)?;
|
|
let original = r.file_bytes_at(&path, Some(":0"))?;
|
|
let bom = original.as_ref().is_some_and(|bytes| has_bom(bytes, enc));
|
|
if let Some(original) = original {
|
|
crate::write::write_index_blob(r, &file.path, decode(&original, enc).as_bytes(), &env)?;
|
|
}
|
|
files.push((file.path, enc, bom));
|
|
}
|
|
run_env(
|
|
r.root(),
|
|
&["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"],
|
|
Some(patch.as_bytes()),
|
|
&env,
|
|
)?;
|
|
for (rel, enc, bom) in files {
|
|
let staged = cli::run_raw_env(r.root(), &["show", &format!(":0:{rel}")], None, &env)?;
|
|
if staged.code == Some(0) {
|
|
let text = std::str::from_utf8(&staged.stdout).map_err(|e| IdeError::invalid(e.to_string()))?;
|
|
crate::write::write_index_blob(r, &rel, &encode(text, enc, bom)?, &[])?;
|
|
} else {
|
|
run(r.root(), &["--literal-pathspecs", "update-index", "--force-remove", "--", &rel], None)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_git_and_plain_patches() {
|
|
let patch = "diff --git a/src/a.rs b/src/a.rs\nindex 1..2 100644\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,2 +1,2 @@\n-old\n+new\n ctx\n\
|
|
diff --git a/n.txt b/n.txt\nnew file mode 100644\n--- /dev/null\n+++ b/n.txt\n@@ -0,0 +1 @@\n+hi\n\
|
|
diff --git a/x b/y\nsimilarity index 90%\nrename from x\nrename to y\n\
|
|
diff --git a/gone b/gone\ndeleted file mode 100644\n--- a/gone\n+++ /dev/null\n@@ -1 +0,0 @@\n--- dashes\n";
|
|
let files = parse_patch(patch);
|
|
let got: Vec<(&str, ChangeKind, u32, u32)> =
|
|
files.iter().map(|f| (f.path.as_str(), f.kind, f.added, f.removed)).collect();
|
|
assert_eq!(
|
|
got,
|
|
vec![
|
|
("src/a.rs", ChangeKind::Modified, 1, 1),
|
|
("n.txt", ChangeKind::Added, 1, 0),
|
|
("y", ChangeKind::Renamed, 0, 0),
|
|
("gone", ChangeKind::Deleted, 0, 1),
|
|
]
|
|
);
|
|
assert_eq!(files[2].old_path.as_deref(), Some("x"));
|
|
|
|
let plain = "--- a/one.txt\t2024-01-01\n+++ b/one.txt\t2024-01-02\n@@ -1 +1 @@\n-a\n+b\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1,2 @@\n a\n+c\n";
|
|
let files = parse_patch(plain);
|
|
assert_eq!(files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec!["one.txt", "two.txt"]);
|
|
assert_eq!((files[1].added, files[1].removed), (1, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn failing_paths_from_git_apply() {
|
|
let err = "error: patch failed: src/a.rs:3\nerror: src/a.rs: patch does not apply\nerror: n.txt: already exists in working directory\n";
|
|
assert_eq!(failing_paths(err), vec!["src/a.rs", "src/a.rs", "n.txt"]);
|
|
}
|
|
}
|