//! 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}, }; 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)>; type FileResult = Result>, String>; pub(crate) type ResultBatch = Vec<(usize, FileResult)>; type PickerRequest = (PathBuf, Vec<(PathBuf, Arc)>); type Request = (u64, u64, Files, Option); pub(crate) struct PickerChanges { pub(crate) root: PathBuf, pub(crate) result: Result, 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, result: Option<(u64, ResultBatch, Option)>, } #[derive(Default)] struct Shared { state: Mutex, wake: Condvar, generation: AtomicU64, stopped: AtomicBool, } pub(crate) struct Worker { shared: Arc, thread: Option>, } impl Worker { pub(crate) fn start() -> io::Result { 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, ) -> 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)> { 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, 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 { 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>, 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, 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>, 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> { fn source(text: &str) -> (Vec, 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;