adbd2c3f81
Bound synchronous full-source syntax parsing, cache dirty comparisons, edit ordinary inline changes in place, retain changed-row undo history, and buffer atomic saves. Skip repository snapshots beyond existing worker limits before copying their text.
461 lines
15 KiB
Rust
461 lines
15 KiB
Rust
//! Read-only source-line changes against Git HEAD or a single Jujutsu @- parent.
|
|
use crate::{
|
|
blame::{git, git_without_literal_pathspecs, run, run_status},
|
|
diff::line_hunks,
|
|
editor::normalize_newlines,
|
|
repository::{
|
|
WorkingFileChange, WorkingFileReader, jj_command, jj_file_pattern, safe_path, store_for_jj,
|
|
},
|
|
};
|
|
use serde::Deserialize;
|
|
use std::{
|
|
fs, io,
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
sync::{
|
|
Arc, Condvar, Mutex,
|
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
|
},
|
|
thread::{self, JoinHandle},
|
|
};
|
|
|
|
pub(crate) const FILE_LIMIT: usize = 16 * 1024 * 1024;
|
|
const BATCH_LIMIT: usize = 64 * 1024 * 1024;
|
|
const FILE_COUNT_LIMIT: usize = 1024;
|
|
type Files = Vec<(usize, PathBuf, Arc<str>)>;
|
|
type FileResult = Result<Vec<Option<Change>>, String>;
|
|
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
|
|
type PickerRequest = (PathBuf, Vec<(PathBuf, Arc<str>)>);
|
|
type Request = (u64, u64, Files, Option<PickerRequest>);
|
|
|
|
pub(crate) struct PickerChanges {
|
|
pub(crate) root: PathBuf,
|
|
pub(crate) result: Result<Vec<WorkingFileChange>, String>,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum GutterStyle {
|
|
#[default]
|
|
Bar,
|
|
Symbols,
|
|
LineNumbers,
|
|
None,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum Change {
|
|
Added,
|
|
Removed,
|
|
Modified,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct State {
|
|
request: Option<Request>,
|
|
result: Option<(u64, ResultBatch, Option<PickerChanges>)>,
|
|
}
|
|
#[derive(Default)]
|
|
struct Shared {
|
|
state: Mutex<State>,
|
|
wake: Condvar,
|
|
generation: AtomicU64,
|
|
stopped: AtomicBool,
|
|
}
|
|
pub(crate) struct Worker {
|
|
shared: Arc<Shared>,
|
|
thread: Option<JoinHandle<()>>,
|
|
}
|
|
impl Worker {
|
|
pub(crate) fn start() -> io::Result<Self> {
|
|
let shared = Arc::new(Shared::default());
|
|
let worker = Arc::clone(&shared);
|
|
let thread = thread::Builder::new()
|
|
.name("vamp-gutter".into())
|
|
.spawn(move || {
|
|
let mut picker_reader = WorkingFileReader::default();
|
|
loop {
|
|
let Ok(mut state) = worker.state.lock() else {
|
|
return;
|
|
};
|
|
while state.request.is_none() && !worker.stopped.load(Ordering::Acquire) {
|
|
let Ok(next) = worker.wake.wait(state) else {
|
|
return;
|
|
};
|
|
state = next;
|
|
}
|
|
if worker.stopped.load(Ordering::Acquire) {
|
|
return;
|
|
}
|
|
let (generation, id, files, picker) = state.request.take().unwrap();
|
|
drop(state);
|
|
let cancelled = || {
|
|
worker.stopped.load(Ordering::Acquire)
|
|
|| worker.generation.load(Ordering::Acquire) != generation
|
|
};
|
|
let mut results = Vec::with_capacity(files.len());
|
|
for (buffer, path, contents) in files {
|
|
if cancelled() {
|
|
break;
|
|
}
|
|
results.push((buffer, read_file(&path, &contents, &cancelled)));
|
|
}
|
|
let picker = picker.map(|(root, buffers)| PickerChanges {
|
|
result: picker_reader.read(&root, &buffers, &cancelled),
|
|
root,
|
|
});
|
|
let Ok(mut state) = worker.state.lock() else {
|
|
return;
|
|
};
|
|
if !cancelled() {
|
|
state.result = Some((id, results, picker));
|
|
}
|
|
}
|
|
})?;
|
|
Ok(Self {
|
|
shared,
|
|
thread: Some(thread),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn request(
|
|
&mut self,
|
|
id: u64,
|
|
files: Files,
|
|
picker: Option<PickerRequest>,
|
|
) -> io::Result<()> {
|
|
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
|
|
size.checked_add(text.len())?
|
|
.checked_add(path.as_os_str().len())
|
|
});
|
|
let picker_size = picker.as_ref().map_or(Some(0), |(root, buffers)| {
|
|
buffers
|
|
.iter()
|
|
.try_fold(root.as_os_str().len(), |size, (path, text)| {
|
|
size.checked_add(text.len())?
|
|
.checked_add(path.as_os_str().len())
|
|
})
|
|
});
|
|
let count = files.len() + picker.as_ref().map_or(0, |(_, buffers)| buffers.len());
|
|
if count > FILE_COUNT_LIMIT
|
|
|| size
|
|
.zip(picker_size)
|
|
.and_then(|(files, picker)| files.checked_add(picker))
|
|
.is_none_or(|size| size > BATCH_LIMIT)
|
|
{
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"Gutter batch exceeds 64 MiB or 1024 files",
|
|
));
|
|
}
|
|
let mut state = self
|
|
.shared
|
|
.state
|
|
.lock()
|
|
.map_err(|_| io::Error::other("Gutter worker stopped"))?;
|
|
let generation = self
|
|
.shared
|
|
.generation
|
|
.fetch_add(1, Ordering::AcqRel)
|
|
.wrapping_add(1);
|
|
state.request = Some((generation, id, files, picker));
|
|
state.result = None;
|
|
self.shared.wake.notify_one();
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch, Option<PickerChanges>)> {
|
|
self.shared.state.lock().ok()?.result.take()
|
|
}
|
|
}
|
|
impl Drop for Worker {
|
|
fn drop(&mut self) {
|
|
let state = self.shared.state.lock();
|
|
self.shared.stopped.store(true, Ordering::Release);
|
|
self.shared.wake.notify_one();
|
|
drop(state);
|
|
if let Some(thread) = self.thread.take() {
|
|
let _ = thread.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn checked(command: Command, cancelled: &dyn Fn() -> bool) -> Result<Vec<u8>, String> {
|
|
run(command, &[], cancelled)?.ok_or_else(|| "Cannot read gutter repository metadata".into())
|
|
}
|
|
|
|
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> FileResult {
|
|
if contents.len() > FILE_LIMIT {
|
|
return Err("Gutter file exceeds 16 MiB".into());
|
|
}
|
|
if contents.as_bytes().contains(&0) {
|
|
return Ok(vec![]);
|
|
}
|
|
match fs::symlink_metadata(path) {
|
|
Ok(metadata) if !metadata.file_type().is_file() => return Ok(vec![]),
|
|
Ok(_) => {}
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
|
Err(error) => return Err(error.to_string()),
|
|
}
|
|
// Check the lexical path before canonicalizing: metadata files and symlinked parents
|
|
// must not acquire the markers of their target outside the checkout.
|
|
let absolute = if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
std::env::current_dir()
|
|
.map_err(|e| e.to_string())?
|
|
.join(path)
|
|
};
|
|
if absolute
|
|
.components()
|
|
.any(|part| part.as_os_str() == ".git" || part.as_os_str() == ".jj")
|
|
{
|
|
return Ok(vec![]);
|
|
}
|
|
let Some(root) = absolute.parent().and_then(|parent| {
|
|
parent
|
|
.ancestors()
|
|
.find(|root| root.join(".jj").exists() || root.join(".git").exists())
|
|
}) else {
|
|
return Ok(vec![]);
|
|
};
|
|
if absolute
|
|
.ancestors()
|
|
.take_while(|part| *part != root)
|
|
.any(|part| {
|
|
fs::symlink_metadata(part).is_ok_and(|metadata| metadata.file_type().is_symlink())
|
|
})
|
|
{
|
|
return Ok(vec![]);
|
|
}
|
|
let relative = absolute.strip_prefix(root).map_err(|e| e.to_string())?;
|
|
if safe_path(relative).is_err() {
|
|
return Ok(vec![]);
|
|
}
|
|
// Resolve ancestors above the checkout, preserving a missing named-buffer tail.
|
|
let root = fs::canonicalize(root).map_err(|e| e.to_string())?;
|
|
let baseline = if root.join(".jj").exists() {
|
|
jj_baseline(&root, relative, cancelled)?
|
|
} else {
|
|
git_baseline(&root, relative, cancelled)?
|
|
};
|
|
let Some(baseline) = baseline else {
|
|
return Ok(vec![]);
|
|
};
|
|
if baseline.contains(&0) {
|
|
return Ok(vec![]);
|
|
}
|
|
let Ok(baseline) = std::str::from_utf8(&baseline) else {
|
|
return Ok(vec![]);
|
|
};
|
|
if cancelled() {
|
|
return Err("Gutter cancelled".into());
|
|
}
|
|
Ok(changes(baseline, contents))
|
|
}
|
|
|
|
fn eligible(
|
|
root: &Path,
|
|
relative: &Path,
|
|
store: Option<&Path>,
|
|
cancelled: &dyn Fn() -> bool,
|
|
) -> Result<bool, String> {
|
|
let command = |literal_pathspecs| {
|
|
let mut command = if literal_pathspecs {
|
|
git(root)
|
|
} else {
|
|
git_without_literal_pathspecs(root)
|
|
};
|
|
if let Some(store) = store {
|
|
command
|
|
.arg("--git-dir")
|
|
.arg(store)
|
|
.arg("--work-tree")
|
|
.arg(root);
|
|
}
|
|
command
|
|
};
|
|
let mut tracked = command(true);
|
|
tracked
|
|
.args(["ls-files", "--cached", "-z", "--"])
|
|
.arg(relative);
|
|
let bytes = checked(tracked, cancelled)?;
|
|
if bytes
|
|
.split(|byte| *byte == 0)
|
|
.any(|entry| entry == relative.as_os_str().as_encoded_bytes())
|
|
{
|
|
return Ok(true);
|
|
}
|
|
let mut ignored = command(false);
|
|
ignored
|
|
.args(["check-ignore", "--no-index", "-q", "--"])
|
|
.arg(relative);
|
|
match run_status(ignored, &[], cancelled)?.0 {
|
|
Some(0) => Ok(false),
|
|
Some(1) => Ok(true),
|
|
_ => Err("Cannot read gutter ignore rules".into()),
|
|
}
|
|
}
|
|
|
|
fn git_baseline(
|
|
root: &Path,
|
|
relative: &Path,
|
|
cancelled: &dyn Fn() -> bool,
|
|
) -> Result<Option<Vec<u8>>, String> {
|
|
let mut head = git(root);
|
|
head.args(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
let (code, head) = run_status(head, &[], cancelled)?;
|
|
if code == Some(1) {
|
|
// An unborn repository still has an index and ignore rules.
|
|
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
|
|
}
|
|
if code != Some(0) {
|
|
return Err("Cannot resolve Git gutter HEAD".into());
|
|
}
|
|
let head = std::str::from_utf8(&head)
|
|
.map_err(|_| "Invalid Git gutter HEAD")?
|
|
.trim();
|
|
let mut tree = git(root);
|
|
tree.args(["ls-tree", "-z", "--full-tree", head, "--"])
|
|
.arg(relative);
|
|
let entries = checked(tree, cancelled)?;
|
|
let Some(entry) = entries
|
|
.split(|byte| *byte == 0)
|
|
.find(|entry| !entry.is_empty())
|
|
else {
|
|
return Ok(eligible(root, relative, None, cancelled)?.then(Vec::new));
|
|
};
|
|
let tab = entry
|
|
.iter()
|
|
.position(|byte| *byte == b'\t')
|
|
.ok_or("Invalid Git gutter tree")?;
|
|
if &entry[tab + 1..] != relative.as_os_str().as_encoded_bytes() {
|
|
return Err("Unexpected Git gutter path".into());
|
|
}
|
|
let fields = std::str::from_utf8(&entry[..tab]).map_err(|_| "Invalid Git gutter tree")?;
|
|
let mut fields = fields.split(' ');
|
|
if !matches!(fields.next(), Some("100644" | "100755")) || fields.next() != Some("blob") {
|
|
return Ok(None);
|
|
}
|
|
let object = fields.next().ok_or("Missing Git gutter blob")?;
|
|
let mut cat = git(root);
|
|
cat.args(["cat-file", "blob", object]);
|
|
checked(cat, cancelled).map(Some)
|
|
}
|
|
|
|
fn jj_file_kind(
|
|
root: &Path,
|
|
revision: &str,
|
|
pattern: &str,
|
|
cancelled: &dyn Fn() -> bool,
|
|
) -> Result<Option<String>, String> {
|
|
let mut command = jj_command(root);
|
|
command.args([
|
|
"file",
|
|
"list",
|
|
"-r",
|
|
revision,
|
|
"-T",
|
|
"json(file_type) ++ \"\\n\"",
|
|
"--",
|
|
pattern,
|
|
]);
|
|
let bytes = checked(command, cancelled)?;
|
|
if bytes.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let kind =
|
|
serde_json::from_slice(&bytes).map_err(|_| "Invalid Jujutsu gutter file metadata")?;
|
|
Ok(Some(kind))
|
|
}
|
|
|
|
fn jj_baseline(
|
|
root: &Path,
|
|
relative: &Path,
|
|
cancelled: &dyn Fn() -> bool,
|
|
) -> Result<Option<Vec<u8>>, String> {
|
|
let Ok(store) = store_for_jj(root) else {
|
|
return Ok(None);
|
|
};
|
|
let Some(path) = relative.to_str() else {
|
|
return Ok(None);
|
|
};
|
|
let mut parents = jj_command(root);
|
|
parents.args([
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
"@-",
|
|
"-T",
|
|
"commit_id ++ \"\\n\"",
|
|
]);
|
|
let bytes = checked(parents, cancelled)?;
|
|
let ids = std::str::from_utf8(&bytes).map_err(|_| "Invalid Jujutsu gutter parents")?;
|
|
let mut ids = ids.lines();
|
|
let Some(parent) = ids.next() else {
|
|
return Ok(None);
|
|
};
|
|
// ponytail: no merged-parent materializer exists here; hide markers on merges
|
|
// rather than report changes against an arbitrary first parent.
|
|
if ids.next().is_some() {
|
|
return Ok(None);
|
|
}
|
|
let pattern = jj_file_pattern(path);
|
|
let baseline_kind = if parent.bytes().all(|byte| byte == b'0') {
|
|
None
|
|
} else {
|
|
jj_file_kind(root, parent, &pattern, cancelled)?
|
|
};
|
|
if let Some(kind) = baseline_kind {
|
|
if !matches!(kind.as_str(), "file" | "conflict") {
|
|
return Ok(None);
|
|
}
|
|
let mut show = jj_command(root);
|
|
show.args(["file", "show", "-r", parent, "-T", "", "--", &pattern]);
|
|
return checked(show, cancelled).map(Some);
|
|
}
|
|
if let Some(kind) = jj_file_kind(root, "@", &pattern, cancelled)? {
|
|
return Ok(matches!(kind.as_str(), "file" | "conflict").then(Vec::new));
|
|
}
|
|
Ok(eligible(root, relative, Some(&store), cancelled)?.then(Vec::new))
|
|
}
|
|
|
|
fn changes(before: &str, after: &str) -> Vec<Option<Change>> {
|
|
fn source(text: &str) -> (Vec<String>, bool) {
|
|
(
|
|
text.split_terminator('\n').map(str::to_owned).collect(),
|
|
text.ends_with('\n'),
|
|
)
|
|
}
|
|
let before = normalize_newlines(before);
|
|
let after = normalize_newlines(after);
|
|
if before == after {
|
|
return vec![None; after.split('\n').count()];
|
|
}
|
|
let (before, before_eof) = source(&before);
|
|
let (after_lines, after_eof) = source(&after);
|
|
let mut markers = vec![None; after.split('\n').count()];
|
|
for hunk in line_hunks(&before, before_eof, &after_lines, after_eof) {
|
|
if hunk.after_len == 0 {
|
|
let anchor = hunk.after_start.min(markers.len() - 1);
|
|
// A deletion sharing a row with a replacement remains modified.
|
|
if markers[anchor].is_none() {
|
|
markers[anchor] = Some(Change::Removed);
|
|
}
|
|
} else {
|
|
for offset in 0..hunk.after_len {
|
|
markers[hunk.after_start + offset] = Some(if offset < hunk.before_len {
|
|
Change::Modified
|
|
} else {
|
|
Change::Added
|
|
});
|
|
}
|
|
}
|
|
}
|
|
markers
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
#[path = "../tests/unit/gutter.rs"]
|
|
mod tests;
|