feat: add Git and Jujutsu gutter indicators

This commit is contained in:
2026-10-13 17:44:29 +02:00
parent e84d70463f
commit 55cdee1a13
14 changed files with 1392 additions and 32 deletions
+797
View File
@@ -0,0 +1,797 @@
//! 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::{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<str>)>;
type FileResult = Result<Vec<Option<Change>>, String>;
pub(crate) type ResultBatch = Vec<(usize, FileResult)>;
type Request = (u64, u64, Files);
#[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)>,
}
#[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 || {
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) = 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 Ok(mut state) = worker.state.lock() else {
return;
};
if !cancelled() {
state.result = Some((id, results));
}
}
})?;
Ok(Self {
shared,
thread: Some(thread),
})
}
pub(crate) fn request(&mut self, id: u64, files: Files) -> io::Result<()> {
let size = files.iter().try_fold(0usize, |size, (_, path, text)| {
size.checked_add(text.len())?
.checked_add(path.as_os_str().len())
});
if files.len() > FILE_COUNT_LIMIT || size.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));
state.result = None;
self.shared.wake.notify_one();
Ok(())
}
pub(crate) fn poll(&mut self) -> Option<(u64, ResultBatch)> {
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))]
mod tests {
use super::*;
use std::ffi::OsString;
static NEXT_TEST: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf);
impl Temporary {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-gutter-test-{}-{}",
std::process::id(),
NEXT_TEST.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Temporary {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(root: &Path, program: &str, args: &[&str]) -> String {
let mut command = Command::new(program);
if program == "jj" {
command.args([
"--config=user.name=\"Gutter Author\"",
"--config=user.email=\"gutter@example.test\"",
"--config=debug.commit-timestamp=\"2001-01-01T00:00:00Z\"",
"--config=debug.operation-timestamp=\"2001-01-01T00:00:00Z\"",
]);
}
let output = command
.args(args)
.current_dir(root)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.env("JJ_CONFIG", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().into()
}
fn git_fixture() -> (Temporary, PathBuf) {
let temp = Temporary::new();
command(&temp.0, "git", &["init", "--quiet", "--template="]);
command(&temp.0, "git", &["config", "user.name", "Gutter Author"]);
command(
&temp.0,
"git",
&["config", "user.email", "gutter@example.test"],
);
let path = temp.0.join("-literal [file]\n\t\".txt");
fs::write(&path, "first\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Baseline"]);
(temp, path)
}
#[test]
fn maps_replacements_surplus_deletions_empty_and_eof_rows() {
use Change::*;
for (before, after, expected) in [
("first\nsecond\n", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\n",
"first\nsecond\n",
vec![None, None, None],
),
("first\rsecond\r", "first\nsecond\n", vec![None, None, None]),
(
"first\r\nsecond\r\nthird\r\n",
"first\nchanged\nthird\n",
vec![None, Some(Modified), None, None],
),
(
"first\nsecond\nthird\n",
"first\rchanged\rthird\r",
vec![None, Some(Modified), None, None],
),
("first\n", "first\nnew\n", vec![None, Some(Added), None]),
(
"old\n",
"changed\nsurplus\n",
vec![Some(Modified), Some(Added), None],
),
(
"old\nextra\nlast\n",
"changed\nlast\n",
vec![Some(Modified), None, None],
),
(
"first\nremoved\nlast\n",
"first\nlast\n",
vec![None, Some(Removed), None],
),
("first\nremoved\n", "first\n", vec![None, Some(Removed)]),
("first\nremoved", "first", vec![Some(Modified)]),
("first\nremoved", "first\n", vec![None, Some(Removed)]),
("removed\n", "", vec![Some(Removed)]),
("", "new\n", vec![Some(Added), None]),
("", "", vec![None]),
("same", "same\n", vec![Some(Modified), None]),
] {
assert_eq!(changes(before, after), expected, "{before:?} -> {after:?}");
}
}
#[test]
fn git_crlf_baseline_matches_normalized_editor_rows() {
let (temp, _) = git_fixture();
let path = temp.0.join("crlf");
fs::write(&path, "first\r\nsecond\r\nthird\r\n").unwrap();
command(&temp.0, "git", &["add", "--", "crlf"]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "CRLF baseline"],
);
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&path, "first\nchanged\nthird\n", &|| false).unwrap(),
vec![None, Some(Change::Modified), None, None]
);
}
#[test]
fn git_head_includes_staged_disk_and_unsaved_edits_without_writes() {
let (temp, path) = git_fixture();
fs::write(&path, "staged\nsecond\nthird\n").unwrap();
command(
&temp.0,
"git",
&["add", "--", path.file_name().unwrap().to_str().unwrap()],
);
fs::write(&path, "staged\ndisk\nthird\n").unwrap();
let index = fs::read(temp.0.join(".git/index")).unwrap();
let head = command(&temp.0, "git", &["rev-parse", "HEAD"]);
assert_eq!(
read_file(&path, "staged\ndisk\nunsaved\n", &|| false).unwrap(),
vec![
Some(Change::Modified),
Some(Change::Modified),
Some(Change::Modified),
None
]
);
assert_eq!(fs::read(temp.0.join(".git/index")).unwrap(), index);
assert_eq!(command(&temp.0, "git", &["rev-parse", "HEAD"]), head);
assert_eq!(fs::read_to_string(&path).unwrap(), "staged\ndisk\nthird\n");
fs::remove_file(&path).unwrap();
assert_eq!(
read_file(&path, "first\nsecond\nthird\n", &|| false).unwrap(),
vec![None, None, None, None]
);
assert_eq!(
read_file(&temp.0.join("new/named"), "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let untracked = temp.0.join("untracked");
fs::write(&untracked, "unsaved\n").unwrap();
assert_eq!(
read_file(&untracked, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
}
#[test]
fn unborn_untracked_missing_ignored_metadata_binary_and_symlink_paths() {
let temp = Temporary::new();
let path = temp.0.join("new/named");
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
command(&temp.0, "git", &["init", "--quiet", "--template="]);
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
let disk = temp.0.join("untracked");
fs::write(&disk, "disk\n").unwrap();
assert_eq!(
read_file(&disk, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "new/\nuntracked\n").unwrap();
assert!(read_file(&path, "unsaved\n", &|| false).unwrap().is_empty());
assert!(read_file(&disk, "unsaved\n", &|| false).unwrap().is_empty());
assert!(
read_file(&temp.0.join(".git/config"), "text\n", &|| false)
.unwrap()
.is_empty()
);
assert!(
read_file(&disk, "binary\0text", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&disk, temp.0.join("link")).unwrap();
assert!(
read_file(&temp.0.join("link"), "text\n", &|| false)
.unwrap()
.is_empty()
);
std::os::unix::fs::symlink(&temp.0, temp.0.join("linked")).unwrap();
assert!(
read_file(&temp.0.join("linked/untracked"), "text\n", &|| false)
.unwrap()
.is_empty()
);
}
#[test]
fn git_non_utf8_filename_is_literal() {
use std::os::unix::ffi::OsStringExt;
let (temp, _) = git_fixture();
let name = OsString::from_vec(b"non-utf8-\xff".to_vec());
let path = temp.0.join(&name);
match fs::write(&path, "old\n") {
Ok(()) => {}
// APFS rejects non-UTF-8 names before repository code can read them.
Err(error) if cfg!(target_os = "macos") && error.raw_os_error() == Some(92) => return,
Err(error) => panic!("Cannot create non-UTF-8 fixture: {error}"),
}
let mut add = git(&temp.0);
add.args(["add", "--"]).arg(&name);
checked(add, &|| false).unwrap();
command(&temp.0, "git", &["commit", "--quiet", "-m", "Byte path"]);
assert_eq!(
read_file(&path, "changed\n", &|| false).unwrap(),
vec![Some(Change::Modified), None]
);
}
#[test]
fn jj_parent_root_colocation_ignore_and_merge_queries_are_read_only() {
for colocate in ["--colocate", "--no-colocate"] {
let temp = Temporary::new();
command(&temp.0, "jj", &["git", "init", colocate, "."]);
let name = "-literal [file]\n\t\".txt";
let path = temp.0.join(name);
fs::write(&path, "original\n").unwrap();
assert_eq!(
read_file(&path, "unsaved\n", &|| false).unwrap(),
vec![Some(Change::Added), None]
);
fs::write(temp.0.join(".gitignore"), "ignored\n").unwrap();
assert!(
read_file(&temp.0.join("ignored"), "text\n", &|| false)
.unwrap()
.is_empty()
);
command(&temp.0, "jj", &["describe", "-m", "Baseline"]);
command(&temp.0, "jj", &["new"]);
let operation = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
],
);
let store = store_for_jj(&temp.0).unwrap();
let refs = fs::read(store.join("HEAD")).unwrap();
fs::write(&path, "disk edit\n").unwrap();
assert_eq!(
read_file(&path, "inserted\noriginal\n", &|| false).unwrap(),
vec![Some(Change::Added), None, None]
);
assert_eq!(fs::read(store.join("HEAD")).unwrap(), refs);
assert_eq!(
command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"op",
"log",
"--no-graph",
"-n",
"1",
"-T",
"id",
]
),
operation
);
let parent = command(
&temp.0,
"jj",
&[
"--ignore-working-copy",
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id",
],
);
command(&temp.0, "jj", &["new", "root()"]);
fs::write(temp.0.join("sibling"), "sibling\n").unwrap();
command(&temp.0, "jj", &["describe", "-m", "Sibling"]);
command(&temp.0, "jj", &["new", &parent, "@"]);
assert!(
read_file(&path, "original\n", &|| false)
.unwrap()
.is_empty()
);
}
}
#[test]
fn worker_retains_only_latest_batch_and_file_errors_keep_good_siblings() {
let (_temp, path) = git_fixture();
let mut worker = Worker::start().unwrap();
let oversized: Arc<str> = "x".repeat(FILE_LIMIT + 1).into();
for id in 0..20 {
worker
.request(
id,
vec![
(5, path.clone(), "first\nchanged\nthird\n".into()),
(8, path.clone(), Arc::clone(&oversized)),
],
)
.unwrap();
}
loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 19);
assert_eq!(result[0].0, 5);
assert_eq!(
result[0].1.as_ref().unwrap(),
&vec![None, Some(Change::Modified), None, None]
);
assert_eq!(result[1].0, 8);
assert_eq!(
result[1].1.as_ref().unwrap_err(),
"Gutter file exceeds 16 MiB"
);
break;
}
thread::yield_now();
}
assert_eq!(
worker
.request(20, vec![(0, path, "x".repeat(BATCH_LIMIT + 1).into())])
.unwrap_err()
.kind(),
io::ErrorKind::InvalidInput
);
}
}