fix: show revision-aware blame in diff buffers

Diff and snapshot sources were excluded before scheduling, and the separate diff renderer never painted attribution. Capture per-side Git provenance, preserve private object stores for historical tabs, and reuse the current-line worker and cache.
This commit is contained in:
Valdemar Lorenzen
2026-10-20 11:47:49 +02:00
parent 8f8d96ead9
commit 4fb1be7972
16 changed files with 819 additions and 91 deletions
+118 -53
View File
@@ -17,7 +17,7 @@ pub(crate) const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>;
type Request = (u64, u64, PathBuf, String);
type Request = (u64, u64, PathBuf, String, Option<RevisionSource>);
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct LineBlame {
@@ -27,6 +27,14 @@ pub(crate) struct LineBlame {
pub(crate) uncommitted: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct RevisionSource {
pub(crate) store: PathBuf,
pub(crate) commit: String,
pub(crate) path: PathBuf,
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
}
#[derive(Default)]
struct State {
request: Option<Request>,
@@ -63,13 +71,16 @@ impl BlameWorker {
if worker.stopped.load(Ordering::Acquire) {
break;
}
let (generation, id, path, contents) = state.request.take().unwrap();
let (generation, id, path, contents, source) = state.request.take().unwrap();
drop(state);
let cancelled = || {
worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation
};
let result = read_file(&path, &contents, &cancelled);
let result = match source {
Some(source) => read_revision(&source, &contents, &cancelled),
None => read_file(&path, &contents, &cancelled),
};
let Ok(mut state) = worker.state.lock() else {
return;
};
@@ -84,7 +95,13 @@ impl BlameWorker {
})
}
pub(crate) fn request(&mut self, id: u64, path: PathBuf, contents: String) -> io::Result<()> {
pub(crate) fn request(
&mut self,
id: u64,
path: PathBuf,
contents: String,
source: Option<RevisionSource>,
) -> io::Result<()> {
if contents.len() > LIMIT {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -101,7 +118,7 @@ impl BlameWorker {
.generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
state.request = Some((generation, id, path, contents));
state.request = Some((generation, id, path, contents, source));
state.result = None;
self.shared.wake.notify_one();
Ok(())
@@ -279,19 +296,93 @@ pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
fn oid(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> {
#[cfg(unix)]
{
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
fn read_revision(
source: &RevisionSource,
contents: &str,
cancelled: &dyn Fn() -> bool,
) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
))
if !oid(&source.commit) {
return Err("Invalid blame commit".into());
}
crate::repository::safe_path(&source.path)?;
let rows = contents.split_inclusive('\n').count();
if rows == 0 || source.commit.bytes().all(|byte| byte == b'0') {
return Ok(None);
}
let mut objects = git(&source.store);
objects.arg("--git-dir").arg(&source.store).args([
"rev-parse",
"--path-format=absolute",
"--git-path",
"objects",
]);
let objects = run(objects, &[], cancelled)?.ok_or("Cannot resolve blame object store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.and_then(|()| fs::create_dir_all(private.0.join("objects/info")))
.map_err(|_| "Cannot prepare private blame repository")?;
// Keep relative alternates anchored in their original object directory.
let mut alternate = vec![b'"'];
for &byte in objects.strip_suffix(b"\n").unwrap_or(&objects) {
if (b' '..=b'~').contains(&byte) && !matches!(byte, b'"' | b'\\') {
alternate.push(byte);
} else {
write!(&mut alternate, "\\{byte:03o}").unwrap();
}
}
alternate.extend_from_slice(b"\"\n");
fs::write(private.0.join("objects/info/alternates"), alternate)
.and_then(|()| fs::write(private.0.join("HEAD"), format!("{}\n", source.commit)))
.map_err(|_| "Cannot prepare blame parent")?;
if source.commit.len() == 64 {
fs::write(
private.0.join("config"),
"[core]\nrepositoryformatversion = 1\n[extensions]\nobjectFormat = sha256\n",
)
.map_err(|_| "Cannot prepare blame object format")?;
}
let command = || {
let mut command = git(&private.0);
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(&private.0);
command
};
let mut present = command();
present
.args(["ls-tree", "-r", "-z", "--full-tree", "HEAD", "--"])
.arg(&source.path);
let Some(entry) = run(present, &[], cancelled)? else {
return Ok(None);
};
let Some(tab) = entry.iter().position(|&byte| byte == b'\t') else {
return Ok(None);
};
if (!entry.starts_with(b"100644 blob ") && !entry.starts_with(b"100755 blob "))
|| entry[tab + 1..].strip_suffix(b"\0") != Some(source.path.as_os_str().as_encoded_bytes())
{
return Ok(None);
}
let mut blame = command();
blame
.args([
"blame",
"--incremental",
"--no-textconv",
"--contents",
"-",
"--",
])
.arg(&source.path);
let output = run(blame, contents.as_bytes(), cancelled)?.ok_or("Cannot read Git blame")?;
parse(&output, rows).map(Some)
}
fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
@@ -320,7 +411,6 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
}) {
return Ok(None);
}
let mut temporary = None;
let mut command = git(root);
if root.join(".jj").exists() {
let mut jj = Command::new("jj");
@@ -352,44 +442,19 @@ fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> Resul
if !oid(parent) {
return Err("Invalid Jujutsu blame metadata".into());
}
let mut repo = root.join(".jj/repo");
if repo.is_file() {
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?;
repo = root
.join(".jj")
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?);
}
let store = repo.join("store");
let target = fs::read(store.join("git_target"))
.map_err(|_| "Only Git-backed Jujutsu blame is supported")?;
let store = fs::canonicalize(
store.join(path_bytes(target.strip_suffix(b"\n").unwrap_or(&target))?),
)
.map_err(|_| "Cannot resolve Jujutsu Git store")?;
let private = Temporary::new()?;
fs::create_dir(private.0.join("refs"))
.map_err(|_| "Cannot prepare private blame repository")?;
#[cfg(unix)]
std::os::unix::fs::symlink(store.join("objects"), private.0.join("objects"))
.map_err(|_| "Cannot link blame objects")?;
#[cfg(not(unix))]
return Err("Jujutsu blame requires Unix object links".into());
fs::write(private.0.join("HEAD"), format!("{parent}\n"))
.map_err(|_| "Cannot prepare blame parent")?;
command
.arg("--git-dir")
.arg(&private.0)
.arg("--work-tree")
.arg(root);
temporary = Some(private);
let store = crate::repository::store_for_jj(root)?;
return read_revision(
&RevisionSource {
store,
commit: parent.into(),
path: relative.to_path_buf(),
keepalive: None,
},
contents,
cancelled,
);
}
let mut present = if let Some(private) = &temporary {
let mut command = git(root);
command.arg("--git-dir").arg(&private.0);
command
} else {
git(root)
};
let mut present = git(root);
let mut object = std::ffi::OsString::from("HEAD:");
object.push(relative);
present.args(["cat-file", "-e"]).arg(object);
+11
View File
@@ -4,6 +4,7 @@ use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
use unicode_segmentation::UnicodeSegmentation;
@@ -141,10 +142,19 @@ pub struct ChangedFile {
pub hunks: Vec<Hunk>,
pub notice: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlameRevision {
pub store: PathBuf,
pub commit: String,
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
}
#[derive(Debug)]
pub struct Comparison {
/// Immutable comparison-context IDs; captured review text also freezes live edits.
pub revisions: [String; 2],
/// Git object stores and immutable attribution baselines for Before/After.
pub blame: [Option<BlameRevision>; 2],
pub before: String,
pub after: String,
pub files: Vec<ChangedFile>,
@@ -670,6 +680,7 @@ impl DiffState {
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
let comparison = self.comparison.get_or_insert_with(|| Comparison {
revisions: Default::default(),
blame: Default::default(),
before: "Resolution".into(),
after: "Other version".into(),
files: Vec::new(),
+15 -4
View File
@@ -1952,6 +1952,7 @@ struct Buffer {
resolver_readonly: bool,
snapshot_document_path: Option<PathBuf>,
snapshot_root: Option<PathBuf>,
snapshot_blame: Option<Arc<crate::blame::RevisionSource>>,
lines: Vec<String>,
saved_lines: Vec<String>,
// Invalidate whenever text or the saved baseline changes, including temporary swaps.
@@ -2012,6 +2013,7 @@ impl Buffer {
resolver_readonly: false,
snapshot_document_path: None,
snapshot_root: None,
snapshot_blame: None,
saved_lines: lines.clone(),
dirty_cache: Cell::new(Some(false)),
disk_stamp,
@@ -2175,6 +2177,7 @@ pub(crate) struct BlameSource {
buffer: usize,
revision: u64,
pub(crate) path: Arc<Path>,
pub(crate) snapshot: Option<Arc<crate::blame::RevisionSource>>,
pub(crate) row: usize,
save_revision: u64,
disk_stamp: Option<FileStamp>,
@@ -5116,6 +5119,14 @@ impl Editor {
};
let source = comparison.root.join(relative);
let root = comparison.root.clone();
let blame = comparison.blame[side].as_ref().map(|baseline| {
Arc::new(crate::blame::RevisionSource {
store: baseline.store.clone(),
commit: baseline.commit.clone(),
keepalive: baseline.keepalive.clone(),
path: relative.clone(),
})
});
let lines = diff.snapshot_lines_at(file_index, focus);
let key = (
diff.comparison_id.expect("snapshot comparison"),
@@ -5155,6 +5166,7 @@ impl Editor {
buffer.path = Some(source);
buffer.snapshot_document_path = Some(document_path);
buffer.snapshot_root = Some(root);
buffer.snapshot_blame = blame;
buffer.lines = lines;
buffer.dirty_cache.set(None);
buffer
@@ -7156,8 +7168,7 @@ impl Editor {
pub(crate) fn blame_source(&self) -> Option<BlameSource> {
if !self.git_blame
|| self.should_quit()
|| self.diff.is_some()
|| self.active.diff_snapshot.is_some()
|| (self.active.diff_snapshot.is_some() && self.active.snapshot_blame.is_none())
|| self.active.reload_error.is_some()
{
return None;
@@ -7166,6 +7177,7 @@ impl Editor {
buffer: self.buffer_id(),
revision: self.text_revision(),
path: Arc::from(self.path()?),
snapshot: self.active.snapshot_blame.clone(),
row: self.cursor().row,
save_revision: self.active.save_revision,
disk_stamp: self.active.disk_stamp,
@@ -7177,13 +7189,12 @@ impl Editor {
pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool {
self.git_blame
&& !self.should_quit()
&& self.diff.is_none()
&& self.active.diff_snapshot.is_none()
&& self.active.reload_error.is_none()
&& source.buffer == self.buffer_id()
&& source.revision == self.text_revision()
&& source.save_revision == self.active.save_revision
&& source.disk_stamp == self.active.disk_stamp
&& source.snapshot == self.active.snapshot_blame
&& Some(source.path.as_ref()) == self.path()
}
+70 -5
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, PullRequestPage,
RepoKind, Revision, RevisionTarget, line_hunks,
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -28,7 +28,8 @@ const WORKING_SNAPSHOT_LIMIT: usize = 512 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(60);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf);
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Temporary(PathBuf);
impl Temporary {
fn new() -> Result<Self, String> {
for _ in 0..100 {
@@ -1054,6 +1055,7 @@ struct PreparedComparison {
selected: [Revision; 2],
selected_ids: [Option<String>; 2],
revisions: [String; 2],
blame: [Option<BlameRevision>; 2],
mode: ComparisonMode,
left: Snapshot,
right: Snapshot,
@@ -1077,7 +1079,7 @@ pub struct Repository {
root: PathBuf,
kind: RepoKind,
store: PathBuf,
temporary: Temporary,
temporary: Arc<Temporary>,
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
remotes: Vec<Remote>,
remote_refs: BTreeMap<(String, String), String>,
@@ -1128,7 +1130,7 @@ impl Repository {
root,
kind,
store,
temporary: Temporary::new()?,
temporary: Arc::new(Temporary::new()?),
private: None,
remotes: vec![],
remote_refs: BTreeMap::new(),
@@ -2390,6 +2392,48 @@ impl Repository {
}
Ok(Some(text(checked(output)?)?.trim().into()))
}
fn blame_baseline(&mut self, target: &RevisionTarget) -> Result<Option<BlameRevision>, String> {
let source = if *target == RevisionTarget::Working {
let id = if self.kind == RepoKind::Git {
self.working_anchor()?
} else {
let parents = text(self.jj(
&self.root,
&[
"log",
"--no-graph",
"-r",
"@-",
"-T",
"commit_id ++ \"\\n\"",
],
)?)?;
let mut parents = parents.lines();
let parent = parents.next();
// A merge has no single attribution baseline.
if parents.next().is_some() {
None
} else {
parent.map(str::to_owned)
}
};
id.map(|id| (self.store.clone(), id))
} else {
Some(self.resolve(target)?)
};
Ok(source
.filter(|(_, id)| !id.bytes().all(|byte| byte == b'0'))
.map(|(store, commit)| BlameRevision {
keepalive: self
.private
.as_ref()
.filter(|(_, private_store)| private_store == &store)
.map(|_| Arc::clone(&self.temporary)),
store,
commit,
}))
}
fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
if before == after || after.is_none() {
return Ok(false);
@@ -2492,11 +2536,16 @@ impl Repository {
} else {
self.snapshot_revision(after_target, &right)?
};
let blame = [
self.blame_baseline(before_target)?,
self.blame_baseline(after_target)?,
];
let mut prepared = PreparedComparison {
id: comparison_id,
selected: [before.clone(), after.clone()],
selected_ids,
revisions: [before_revision, after_revision],
blame,
mode,
left,
right,
@@ -2614,10 +2663,12 @@ impl Repository {
}
let right = self.working_snapshot(buffers)?;
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
let blame = self.blame_baseline(&RevisionTarget::Working)?;
let mut prepared = self.comparison.take().unwrap();
let previous_right = std::mem::replace(&mut prepared.right, right);
let previous_files = std::mem::take(&mut prepared.files);
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision);
let previous_blame = std::mem::replace(&mut prepared.blame[1], blame);
let result = self
.build_comparison(&mut prepared, false)
.and_then(|comparison| {
@@ -2640,6 +2691,7 @@ impl Repository {
prepared.right = previous_right;
prepared.files = previous_files;
prepared.revisions[1] = previous_revision;
prepared.blame[1] = previous_blame;
}
self.comparison = Some(prepared);
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
@@ -2671,13 +2723,16 @@ impl Repository {
&& follow_working
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
let mut revisions = prepared.revisions.clone();
let mut blame = prepared.blame.clone();
let (left, before_label) = if branch {
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?;
let label = format!("merge base of {label} and {after_label} · {base}");
revisions[0] = base.clone();
blame[0] = self.blame_baseline(&RevisionTarget::Commit(base.clone()))?;
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
} else if changed_before {
revisions[0] = ids[0].clone().unwrap();
blame[0] = self.blame_baseline(&RevisionTarget::Commit(revisions[0].clone()))?;
(
Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
label,
@@ -2699,6 +2754,13 @@ impl Repository {
ids[1].clone().unwrap()
};
}
if right.is_some() {
blame[1] = self.blame_baseline(&if advance_working {
RevisionTarget::Working
} else {
RevisionTarget::Commit(revisions[1].clone())
})?;
}
let pinned_checkout = ids[1]
.as_deref()
.map(|id| self.pinned_checkout(id))
@@ -2711,6 +2773,7 @@ impl Repository {
let previous_before = std::mem::replace(&mut prepared.before, before_label);
let previous_editable = prepared.editable_after;
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
let previous_blame = std::mem::replace(&mut prepared.blame, blame);
prepared.editable_after =
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
let result = self
@@ -2747,6 +2810,7 @@ impl Repository {
prepared.before = previous_before;
prepared.editable_after = previous_editable;
prepared.revisions = previous_revisions;
prepared.blame = previous_blame;
}
self.comparison = Some(prepared);
result
@@ -2861,6 +2925,7 @@ impl Repository {
};
Comparison {
revisions: prepared.revisions.clone(),
blame: prepared.blame.clone(),
before: prepared.before.clone(),
after: prepared.after.clone(),
files,
+1
View File
@@ -1690,6 +1690,7 @@ impl EditorSession {
self.blame_request_id,
source.path.to_path_buf(),
self.editor.lines().join("\n"),
source.snapshot.as_deref().cloned(),
)
.is_err()
{
+34 -16
View File
@@ -2665,22 +2665,13 @@ fn draw_document(
.find(|(_, (row, _, _))| *row == cursor.row)
{
let line = &editor.lines()[cursor.row];
let end = display_column(line, usize::MAX);
let start = end.saturating_sub(segment.start).saturating_add(2);
let label_width = UnicodeWidthStr::width(label);
if end >= segment.start
&& end <= segment.end
&& (!wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len())
&& start.saturating_add(label_width) <= width
{
frame.render_widget(
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
Rect::new(
viewport.x + start as u16,
viewport.y + screen_row as u16,
label_width as u16,
1,
),
if !wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len() {
paint_inline_blame(
frame,
label,
line,
Rect::new(viewport.x, viewport.y + screen_row as u16, width as u16, 1),
segment.start,
);
}
}
@@ -3236,6 +3227,18 @@ fn paint_editor_line(
}
}
fn paint_inline_blame(frame: &mut Frame, label: &str, line: &str, area: Rect, left: usize) {
let end = display_column(line, usize::MAX);
let start = end.saturating_sub(left).saturating_add(2);
let label_width = UnicodeWidthStr::width(label);
if end >= left && start.saturating_add(label_width) <= usize::from(area.width) {
frame.render_widget(
Paragraph::new(label).style(Style::default().fg(Color::DarkGray)),
Rect::new(area.x + start as u16, area.y, label_width as u16, 1),
);
}
}
fn paint_diff_editor(
frame: &mut Frame,
editor: &Editor,
@@ -3250,6 +3253,7 @@ fn paint_diff_editor(
let source_cursor = editor.cursor();
let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column);
let mut cursor = None;
let mut blame_area = None;
let focus = view.info.focus;
for (screen, display, segments) in
diff_screen_rows(view.info.display_len, top, area.height, wrapped)
@@ -3283,6 +3287,20 @@ fn paint_diff_editor(
cursor = Some(Position::new(area.x + (column - left) as u16, line_area.y));
}
}
if source == source_cursor.row
&& (segments.is_none()
|| wrapped.is_some_and(|rows| {
rows[screen].final_segments[usize::from(focus == Focus::After)]
}))
{
blame_area = Some((Rect::new(area.x, line_area.y, area.width, 1), left));
}
}
if cursor.is_some()
&& let Some((area, left)) = blame_area
&& let Some(label) = editor.line_blame()
{
paint_inline_blame(frame, label, &editor.lines()[source_cursor.row], area, left);
}
cursor
}