feat: edit working-checkout comparisons with normal editor actions

This commit is contained in:
2026-10-11 02:13:16 +02:00
parent 704dce2539
commit 9a34669c08
16 changed files with 2277 additions and 647 deletions
+87 -68
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, FileStatus, Hunk, RepoKind, Revision,
RevisionTarget,
Catalog, Category, ChangedFile, Comparison, FileStatus, RepoKind, Revision, RevisionTarget,
line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -934,7 +934,7 @@ impl Repository {
}
Ok(snapshot)
}
fn working_snapshot(&self) -> Result<Snapshot, String> {
fn working_snapshot(&self, buffers: &[(PathBuf, String)]) -> Result<Snapshot, String> {
let mut tracked = BTreeSet::new();
let mut submodules = BTreeSet::new();
if self.kind == RepoKind::Git {
@@ -1017,6 +1017,32 @@ impl Repository {
}
}
}
let overlays: BTreeMap<_, _> = buffers
.iter()
.filter_map(|(path, text)| {
let relative = path.strip_prefix(&self.root).ok()?;
if safe_path(relative).is_err()
|| !path.is_absolute()
|| text.as_bytes().contains(&0)
{
return None;
}
// Reject symlinks in every component, not just the final file.
let mut full = self.root.clone();
for component in relative.components() {
full.push(component);
let metadata = fs::symlink_metadata(&full).ok()?;
if metadata.file_type().is_symlink() {
return None;
}
}
fs::symlink_metadata(&full)
.ok()?
.is_file()
.then_some((relative.to_path_buf(), text))
})
.collect();
paths.extend(overlays.keys().cloned());
let mut total = 0;
let mut snapshot = BTreeMap::new();
for path in paths {
@@ -1110,6 +1136,20 @@ impl Repository {
path.display()
));
}
let bytes = if !bytes.contains(&0)
&& std::str::from_utf8(&bytes).is_ok()
&& let Some(text) = overlays.get(&path)
{
if text.len() > FILE_LIMIT {
return Err(format!(
"{} exceeds the 16 MiB comparison file limit",
path.display()
));
}
text.as_bytes().to_vec()
} else {
bytes
};
#[cfg(unix)]
let executable = {
use std::os::unix::fs::PermissionsExt;
@@ -1146,16 +1186,24 @@ impl Repository {
}
fn snapshot(&mut self, target: &RevisionTarget) -> Result<Snapshot, String> {
if *target == RevisionTarget::Working {
self.working_snapshot()
self.working_snapshot(&[])
} else {
let (store, id) = self.resolve(target)?;
self.commit_snapshot(&store, &id)
}
}
pub fn compare(&mut self, before: &Revision, after: &Revision) -> Result<Comparison, String> {
pub fn compare(
&mut self,
before: &Revision,
after: &Revision,
buffers: &[(PathBuf, String)],
) -> Result<Comparison, String> {
let left = self.snapshot(&before.target)?;
let right = if before.target == after.target {
let working_after = after.target == RevisionTarget::Working;
let right = if before.target == after.target && (!working_after || buffers.is_empty()) {
None
} else if working_after {
Some(self.working_snapshot(buffers)?)
} else {
Some(self.snapshot(&after.target)?)
};
@@ -1199,15 +1247,36 @@ impl Repository {
removed.insert(j);
}
}
let files = files
let files: Vec<_> = files
.into_iter()
.enumerate()
.filter_map(|(i, f)| (!removed.contains(&i)).then_some(f))
.collect();
let editable_paths = if working_after {
files
.iter()
.filter(|file| {
right.get(&file.path).is_some_and(|data| {
matches!(data.mode.as_str(), "100644" | "100755")
&& !data.bytes.contains(&0)
&& std::str::from_utf8(&data.bytes).is_ok()
&& file
.notice
.as_deref()
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
})
})
.map(|file| file.path.clone())
.collect()
} else {
BTreeSet::new()
};
Ok(Comparison {
before: before.label.clone(),
after: after.label.clone(),
files,
root: self.root.clone(),
editable_paths,
})
}
fn changed_file(
@@ -1253,38 +1322,15 @@ impl Repository {
eof_label(after)
));
}
let before_path = self.temporary.0.join("before");
let after_path = self.temporary.0.join("after");
fs::write(&before_path, a).map_err(|e| e.to_string())?;
fs::write(&after_path, b).map_err(|e| e.to_string())?;
let mut cmd = Command::new("git");
cmd.args([
"--no-pager",
"diff",
"--no-index",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--text",
"--unified=0",
"--",
])
.arg(&before_path)
.arg(&after_path)
.current_dir(&self.temporary.0);
let output = run(cmd, vec![], &self.cancelled)?;
if !matches!(output.code, Some(0 | 1)) {
return Err(format!(
"Cannot calculate diff hunks: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let patch = text(output.stdout)?;
let mut hunks = vec![];
for line in patch.lines().filter(|l| l.starts_with("@@ ")) {
hunks.push(parse_hunk(line)?);
}
(lines(a), lines(b), hunks)
let before_lines = lines(a);
let after_lines = lines(b);
let hunks = line_hunks(
&before_lines,
a.ends_with(b"\n"),
&after_lines,
b.ends_with(b"\n"),
);
(before_lines, after_lines, hunks)
};
notices.dedup();
Ok(ChangedFile {
@@ -1293,6 +1339,8 @@ impl Repository {
status,
before: before_lines,
after: after_lines,
before_eof: a.ends_with(b"\n"),
after_eof: b.ends_with(b"\n"),
hunks,
notice: if notices.is_empty() {
None
@@ -1318,35 +1366,6 @@ fn eof_label(file: Option<&FileData>) -> &'static str {
Some(_) => "missing",
}
}
fn parse_hunk(line: &str) -> Result<Hunk, String> {
fn range(value: &str) -> Result<(usize, usize), String> {
let (start, len) = value[1..].split_once(',').unwrap_or((&value[1..], "1"));
let start: usize = start.parse().map_err(|_| "Invalid diff hunk start")?;
let len: usize = len.parse().map_err(|_| "Invalid diff hunk length")?;
Ok((
if len == 0 {
start
} else {
start
.checked_sub(1)
.ok_or("Invalid nonempty diff hunk start")?
},
len,
))
}
let mut fields = line.split_whitespace();
if fields.next() != Some("@@") {
return Err("Invalid diff hunk header".into());
}
let (before_start, before_len) = range(fields.next().ok_or("Missing Before hunk range")?)?;
let (after_start, after_len) = range(fields.next().ok_or("Missing After hunk range")?)?;
Ok(Hunk {
before_start,
before_len,
after_start,
after_len,
})
}
#[cfg(all(test, unix))]
mod tests {