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
+1 -1
View File
@@ -1,6 +1,6 @@
- [x] improve: checkmark persistance should be preserved in a diff where a certain file hasn't changed. - [x] improve: checkmark persistance should be preserved in a diff where a certain file hasn't changed.
- this is to avoid that changing a single file will cause all reviewed files to drop the reviewed state, making it harder to spot what actually requires a re-review and what can be ignored. - this is to avoid that changing a single file will cause all reviewed files to drop the reviewed state, making it harder to spot what actually requires a re-review and what can be ignored.
- [ ] bug: diff view - git blame doesn't show in diff buffers for some reason. - [x] bug: diff view - git blame doesn't show in diff buffers for some reason.
- [ ] feature: diff view - GitHub code review integration after the local review UX is usable. - [ ] feature: diff view - GitHub code review integration after the local review UX is usable.
- publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes.
- preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission. - preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission.
+2 -2
View File
@@ -12,7 +12,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks. - `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured. - `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
- `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply. - `shell`: runs explicit `:!` commands off the input thread with inherited environment, closed stdin, and bounded stdout/stderr capture. Each command has a private Unix process group, killed and reaped on worker drop or shell completion. Session owns concurrent workers and combines their delayed spinners in one shared status segment; Editor owns the read-only output popup, queued completed results, and modal scrolling/dismissal state. No repository-command environment overrides or timeouts apply.
- `blame`: owns local current-line Git attribution against the supplied in-memory text and a cancellable background worker. Git-backed jj uses its working-copy parent's commit through an isolated temporary Git store; active refs/checkouts remain untouched. - `blame`: owns current-line Git attribution against supplied in-memory text and a cancellable background worker. Historical buffers capture their exact Git store, commit, and side-specific path; private stores remain alive while snapshots or requests reference them. Git-backed jj uses an isolated temporary Git store; active refs/checkouts remain untouched.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog. Virtual diff leaves retain their original catalog input index for caller-owned annotations. Normal scanned leaves accept optional repository status and old-path metadata without filtering unchanged files; missing tracked deletions add virtual leaves and required directories. Metadata-only updates preserve query, selection, expansion, and view without rebuilding the catalog.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies. - `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
@@ -58,7 +58,7 @@ Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated orde
Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning. Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning.
Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity. Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity.
Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, and path; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Rendering uses only spare cells after the current logical line's final visible continuation and hides it when the primary caret is offscreen. Disabling drops the worker; comparisons and immutable buffers never schedule live blame. Current-line blame is enabled by default. Session loads whole-file incremental attribution into one active-buffer cache keyed by buffer/text/save revision, disk stamp, path, and optional historical source; cursor, tab, and pane movements within that source reuse it without copying file text or issuing repository commands. Cold results select the current row when they arrive. Edits, saves, source changes, disk refreshes, focus regain, and disabling invalidate attribution; stale results cannot replace a newer source. Comparison provenance follows accepted snapshot/checkout/bookmark refreshes and is retained by immutable buffers independently of their original comparison. Historical queries use a private HEAD and object alternates, never live-file existence or the current checkout baseline. Metadata is shared per commit with `Arc`, and same-commit navigation reuses the sanitized label. Ordinary and split/unified diff rendering use only spare cells after the current logical line's final visible continuation and hide the label when the primary caret is offscreen. Disabling drops the worker.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion, optional repository status and old path). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors and sanitized rename labels, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
+2
View File
@@ -90,6 +90,8 @@ Shared-workspace parity checks compare ordinary and resolver diff geometry/conte
Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart. Repository gutter checks cover LF/CRLF normalization, additions/replacements/deletion anchors, staged/disk/unsaved Git text, unborn and missing named files, ignored/unsupported paths, read-only colocated/non-colocated jj queries, stale results and per-buffer isolation, plus rendered modes, colors, current-line precedence, wrap/scroll and narrow/split geometry. Live PTY smoke verifies all four modes, edit/undo refresh, external Git HEAD changes, and jj parent changes without editor restart.
Blame checks cover selected historical commits rather than live HEAD, renamed/deleted paths, unsafe/nonregular sources, Git-backed non-colocated jj, and privately fetched objects surviving comparison/repository teardown until the final snapshot releases them. Render/session regressions cover both split/unified sides, stale side results, wrapping, narrow panes, detached historical tabs, and Working attribution. Live PTY smoke opens both historical sides and unified view after deleting the file from the checkout, observes the correct commit labels, opens a historical tab, and exits with unchanged HEAD and tracked state.
Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling. Normal-picker checks cover staged/disk/unsaved status, binary and mode changes, exact and ambiguous renames, deleted paths, unborn repositories, aliases and unusual filenames, ignores and safe roots, linked worktrees, and read-only colocated/non-colocated/conflicted jj parents. Catalog/render checks retain unchanged rows, query/selection/expansion and Tree/List navigation, clear stale metadata, restore recreated files, and sanitize rename labels. Live PTY smoke checks A/M/D/R colors in floating and docked pickers with the line gutter disabled, Git HEAD/jj parent refresh, and unchanged jj operation state during polling.
Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior. Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior.
+2 -2
View File
@@ -258,9 +258,9 @@ Text wraps at word boundaries where possible, with grapheme-safe breaks for long
## Current-line Git blame ## Current-line Git blame
Inline blame is enabled by default in ordinary live file buffers. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Git-backed jj repositories use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit attribution. Inline blame is enabled by default in live file buffers and on the focused side of split/unified diffs. The primary caret's line shows its author, commit summary, and short commit ID after the document text; changed or newly inserted tracked-file lines show **Uncommitted**, including unsaved edits. Live Git-backed jj buffers use the working-copy revision's single parent, not a potentially stale Git HEAD. Merge working copies with multiple parents omit live attribution.
Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Missing, untracked, unborn, and non-repository files have no indicator; immutable historical buffers and comparison views do not receive live-file attribution. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering. Loading happens in the background without network access or changing repository refs/checkouts. Attribution is loaded once for the active buffer's in-memory contents; navigation then selects cached lines immediately, and movement during a cold load does not restart it. Editing, saving, reloading, changing source buffers, regaining terminal focus, or toggling blame invalidates the cached attribution. Historical sides use their captured commit and side-specific path, including renamed or deleted files no longer on disk; historical tabs retain that source and any privately fetched objects after the comparison closes. Frozen Working sides use their captured parent baseline rather than a later checkout. Missing, untracked, unborn, and non-repository live files have no indicator. The indicator is hidden when the caret is offscreen or the full label cannot fit; wrapped lines use their final visible continuation without covering text, selections, or the caret. Popups retain their normal layering.
Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history. Use `:git_blame.toggle`, the palette action, or remappable `config.core.git_blame.toggle`; there is no default keybinding. Set `config.git_blame = false` to disable it at startup. Disabling stops the worker and clears the label without changing text or undo history.
+118 -53
View File
@@ -17,7 +17,7 @@ pub(crate) const LIMIT: usize = 16 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(5); const TIMEOUT: Duration = Duration::from_secs(5);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
type ResultFile = Result<Option<Vec<Option<Arc<LineBlame>>>>, String>; 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)] #[derive(Debug, PartialEq, Eq)]
pub(crate) struct LineBlame { pub(crate) struct LineBlame {
@@ -27,6 +27,14 @@ pub(crate) struct LineBlame {
pub(crate) uncommitted: bool, 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)] #[derive(Default)]
struct State { struct State {
request: Option<Request>, request: Option<Request>,
@@ -63,13 +71,16 @@ impl BlameWorker {
if worker.stopped.load(Ordering::Acquire) { if worker.stopped.load(Ordering::Acquire) {
break; break;
} }
let (generation, id, path, contents) = state.request.take().unwrap(); let (generation, id, path, contents, source) = state.request.take().unwrap();
drop(state); drop(state);
let cancelled = || { let cancelled = || {
worker.stopped.load(Ordering::Acquire) worker.stopped.load(Ordering::Acquire)
|| worker.generation.load(Ordering::Acquire) != generation || 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 { let Ok(mut state) = worker.state.lock() else {
return; 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 { if contents.len() > LIMIT {
return Err(io::Error::new( return Err(io::Error::new(
io::ErrorKind::InvalidInput, io::ErrorKind::InvalidInput,
@@ -101,7 +118,7 @@ impl BlameWorker {
.generation .generation
.fetch_add(1, Ordering::AcqRel) .fetch_add(1, Ordering::AcqRel)
.wrapping_add(1); .wrapping_add(1);
state.request = Some((generation, id, path, contents)); state.request = Some((generation, id, path, contents, source));
state.result = None; state.result = None;
self.shared.wake.notify_one(); self.shared.wake.notify_one();
Ok(()) Ok(())
@@ -279,19 +296,93 @@ pub(crate) fn git_without_literal_pathspecs(root: &Path) -> Command {
fn oid(value: &str) -> bool { fn oid(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
} }
fn path_bytes(bytes: &[u8]) -> Result<PathBuf, String> { fn read_revision(
#[cfg(unix)] source: &RevisionSource,
contents: &str,
cancelled: &dyn Fn() -> bool,
) -> ResultFile {
if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into());
}
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())
{ {
use std::{ffi::OsString, os::unix::ffi::OsStringExt}; return Ok(None);
Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(
std::str::from_utf8(bytes).map_err(|_| "Invalid repository path")?,
))
} }
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 { fn read_file(path: &Path, contents: &str, cancelled: &dyn Fn() -> bool) -> ResultFile {
if contents.len() > LIMIT { if contents.len() > LIMIT {
return Err("Blame input exceeds 16 MiB".into()); 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); return Ok(None);
} }
let mut temporary = None;
let mut command = git(root); let mut command = git(root);
if root.join(".jj").exists() { if root.join(".jj").exists() {
let mut jj = Command::new("jj"); 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) { if !oid(parent) {
return Err("Invalid Jujutsu blame metadata".into()); return Err("Invalid Jujutsu blame metadata".into());
} }
let mut repo = root.join(".jj/repo"); let store = crate::repository::store_for_jj(root)?;
if repo.is_file() { return read_revision(
let pointer = fs::read(&repo).map_err(|_| "Cannot read Jujutsu repository pointer")?; &RevisionSource {
repo = root store,
.join(".jj") commit: parent.into(),
.join(path_bytes(pointer.strip_suffix(b"\n").unwrap_or(&pointer))?); path: relative.to_path_buf(),
keepalive: None,
},
contents,
cancelled,
);
} }
let store = repo.join("store"); let mut present = git(root);
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 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 object = std::ffi::OsString::from("HEAD:"); let mut object = std::ffi::OsString::from("HEAD:");
object.push(relative); object.push(relative);
present.args(["cat-file", "-e"]).arg(object); present.args(["cat-file", "-e"]).arg(object);
+11
View File
@@ -4,6 +4,7 @@ use std::{
borrow::Cow, borrow::Cow,
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
@@ -141,10 +142,19 @@ pub struct ChangedFile {
pub hunks: Vec<Hunk>, pub hunks: Vec<Hunk>,
pub notice: Option<String>, 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)] #[derive(Debug)]
pub struct Comparison { pub struct Comparison {
/// Immutable comparison-context IDs; captured review text also freezes live edits. /// Immutable comparison-context IDs; captured review text also freezes live edits.
pub revisions: [String; 2], pub revisions: [String; 2],
/// Git object stores and immutable attribution baselines for Before/After.
pub blame: [Option<BlameRevision>; 2],
pub before: String, pub before: String,
pub after: String, pub after: String,
pub files: Vec<ChangedFile>, 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) { pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
let comparison = self.comparison.get_or_insert_with(|| Comparison { let comparison = self.comparison.get_or_insert_with(|| Comparison {
revisions: Default::default(), revisions: Default::default(),
blame: Default::default(),
before: "Resolution".into(), before: "Resolution".into(),
after: "Other version".into(), after: "Other version".into(),
files: Vec::new(), files: Vec::new(),
+15 -4
View File
@@ -1952,6 +1952,7 @@ struct Buffer {
resolver_readonly: bool, resolver_readonly: bool,
snapshot_document_path: Option<PathBuf>, snapshot_document_path: Option<PathBuf>,
snapshot_root: Option<PathBuf>, snapshot_root: Option<PathBuf>,
snapshot_blame: Option<Arc<crate::blame::RevisionSource>>,
lines: Vec<String>, lines: Vec<String>,
saved_lines: Vec<String>, saved_lines: Vec<String>,
// Invalidate whenever text or the saved baseline changes, including temporary swaps. // Invalidate whenever text or the saved baseline changes, including temporary swaps.
@@ -2012,6 +2013,7 @@ impl Buffer {
resolver_readonly: false, resolver_readonly: false,
snapshot_document_path: None, snapshot_document_path: None,
snapshot_root: None, snapshot_root: None,
snapshot_blame: None,
saved_lines: lines.clone(), saved_lines: lines.clone(),
dirty_cache: Cell::new(Some(false)), dirty_cache: Cell::new(Some(false)),
disk_stamp, disk_stamp,
@@ -2175,6 +2177,7 @@ pub(crate) struct BlameSource {
buffer: usize, buffer: usize,
revision: u64, revision: u64,
pub(crate) path: Arc<Path>, pub(crate) path: Arc<Path>,
pub(crate) snapshot: Option<Arc<crate::blame::RevisionSource>>,
pub(crate) row: usize, pub(crate) row: usize,
save_revision: u64, save_revision: u64,
disk_stamp: Option<FileStamp>, disk_stamp: Option<FileStamp>,
@@ -5116,6 +5119,14 @@ impl Editor {
}; };
let source = comparison.root.join(relative); let source = comparison.root.join(relative);
let root = comparison.root.clone(); 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 lines = diff.snapshot_lines_at(file_index, focus);
let key = ( let key = (
diff.comparison_id.expect("snapshot comparison"), diff.comparison_id.expect("snapshot comparison"),
@@ -5155,6 +5166,7 @@ impl Editor {
buffer.path = Some(source); buffer.path = Some(source);
buffer.snapshot_document_path = Some(document_path); buffer.snapshot_document_path = Some(document_path);
buffer.snapshot_root = Some(root); buffer.snapshot_root = Some(root);
buffer.snapshot_blame = blame;
buffer.lines = lines; buffer.lines = lines;
buffer.dirty_cache.set(None); buffer.dirty_cache.set(None);
buffer buffer
@@ -7156,8 +7168,7 @@ impl Editor {
pub(crate) fn blame_source(&self) -> Option<BlameSource> { pub(crate) fn blame_source(&self) -> Option<BlameSource> {
if !self.git_blame if !self.git_blame
|| self.should_quit() || self.should_quit()
|| self.diff.is_some() || (self.active.diff_snapshot.is_some() && self.active.snapshot_blame.is_none())
|| self.active.diff_snapshot.is_some()
|| self.active.reload_error.is_some() || self.active.reload_error.is_some()
{ {
return None; return None;
@@ -7166,6 +7177,7 @@ impl Editor {
buffer: self.buffer_id(), buffer: self.buffer_id(),
revision: self.text_revision(), revision: self.text_revision(),
path: Arc::from(self.path()?), path: Arc::from(self.path()?),
snapshot: self.active.snapshot_blame.clone(),
row: self.cursor().row, row: self.cursor().row,
save_revision: self.active.save_revision, save_revision: self.active.save_revision,
disk_stamp: self.active.disk_stamp, disk_stamp: self.active.disk_stamp,
@@ -7177,13 +7189,12 @@ impl Editor {
pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool { pub(crate) fn blame_file_source_is_current(&self, source: &BlameSource) -> bool {
self.git_blame self.git_blame
&& !self.should_quit() && !self.should_quit()
&& self.diff.is_none()
&& self.active.diff_snapshot.is_none()
&& self.active.reload_error.is_none() && self.active.reload_error.is_none()
&& source.buffer == self.buffer_id() && source.buffer == self.buffer_id()
&& source.revision == self.text_revision() && source.revision == self.text_revision()
&& source.save_revision == self.active.save_revision && source.save_revision == self.active.save_revision
&& source.disk_stamp == self.active.disk_stamp && source.disk_stamp == self.active.disk_stamp
&& source.snapshot == self.active.snapshot_blame
&& Some(source.path.as_ref()) == self.path() && Some(source.path.as_ref()) == self.path()
} }
+70 -5
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison. //! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{ use crate::diff::{
Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, PullRequestPage, BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
RepoKind, Revision, RevisionTarget, line_hunks, PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
}; };
use std::{ use std::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
@@ -28,7 +28,8 @@ const WORKING_SNAPSHOT_LIMIT: usize = 512 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(60); const TIMEOUT: Duration = Duration::from_secs(60);
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
struct Temporary(PathBuf); #[derive(Debug, PartialEq, Eq)]
pub(crate) struct Temporary(PathBuf);
impl Temporary { impl Temporary {
fn new() -> Result<Self, String> { fn new() -> Result<Self, String> {
for _ in 0..100 { for _ in 0..100 {
@@ -1054,6 +1055,7 @@ struct PreparedComparison {
selected: [Revision; 2], selected: [Revision; 2],
selected_ids: [Option<String>; 2], selected_ids: [Option<String>; 2],
revisions: [String; 2], revisions: [String; 2],
blame: [Option<BlameRevision>; 2],
mode: ComparisonMode, mode: ComparisonMode,
left: Snapshot, left: Snapshot,
right: Snapshot, right: Snapshot,
@@ -1077,7 +1079,7 @@ pub struct Repository {
root: PathBuf, root: PathBuf,
kind: RepoKind, kind: RepoKind,
store: PathBuf, store: PathBuf,
temporary: Temporary, temporary: Arc<Temporary>,
private: Option<(PathBuf, PathBuf)>, // workspace and Git object store private: Option<(PathBuf, PathBuf)>, // workspace and Git object store
remotes: Vec<Remote>, remotes: Vec<Remote>,
remote_refs: BTreeMap<(String, String), String>, remote_refs: BTreeMap<(String, String), String>,
@@ -1128,7 +1130,7 @@ impl Repository {
root, root,
kind, kind,
store, store,
temporary: Temporary::new()?, temporary: Arc::new(Temporary::new()?),
private: None, private: None,
remotes: vec![], remotes: vec![],
remote_refs: BTreeMap::new(), remote_refs: BTreeMap::new(),
@@ -2390,6 +2392,48 @@ impl Repository {
} }
Ok(Some(text(checked(output)?)?.trim().into())) 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> { fn working_advanced(&self, before: Option<&str>, after: Option<&str>) -> Result<bool, String> {
if before == after || after.is_none() { if before == after || after.is_none() {
return Ok(false); return Ok(false);
@@ -2492,11 +2536,16 @@ impl Repository {
} else { } else {
self.snapshot_revision(after_target, &right)? self.snapshot_revision(after_target, &right)?
}; };
let blame = [
self.blame_baseline(before_target)?,
self.blame_baseline(after_target)?,
];
let mut prepared = PreparedComparison { let mut prepared = PreparedComparison {
id: comparison_id, id: comparison_id,
selected: [before.clone(), after.clone()], selected: [before.clone(), after.clone()],
selected_ids, selected_ids,
revisions: [before_revision, after_revision], revisions: [before_revision, after_revision],
blame,
mode, mode,
left, left,
right, right,
@@ -2614,10 +2663,12 @@ impl Repository {
} }
let right = self.working_snapshot(buffers)?; let right = self.working_snapshot(buffers)?;
let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?; let revision = self.snapshot_revision(&RevisionTarget::Working, &right)?;
let blame = self.blame_baseline(&RevisionTarget::Working)?;
let mut prepared = self.comparison.take().unwrap(); let mut prepared = self.comparison.take().unwrap();
let previous_right = std::mem::replace(&mut prepared.right, right); let previous_right = std::mem::replace(&mut prepared.right, right);
let previous_files = std::mem::take(&mut prepared.files); let previous_files = std::mem::take(&mut prepared.files);
let previous_revision = std::mem::replace(&mut prepared.revisions[1], revision); 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 let result = self
.build_comparison(&mut prepared, false) .build_comparison(&mut prepared, false)
.and_then(|comparison| { .and_then(|comparison| {
@@ -2640,6 +2691,7 @@ impl Repository {
prepared.right = previous_right; prepared.right = previous_right;
prepared.files = previous_files; prepared.files = previous_files;
prepared.revisions[1] = previous_revision; prepared.revisions[1] = previous_revision;
prepared.blame[1] = previous_blame;
} }
self.comparison = Some(prepared); self.comparison = Some(prepared);
result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison))) result.map(|comparison| Some(CheckoutRefresh::Checkout(comparison)))
@@ -2671,13 +2723,16 @@ impl Repository {
&& follow_working && follow_working
&& self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?; && self.working_advanced(prepared.working_anchor.as_deref(), anchor.as_deref())?;
let mut revisions = prepared.revisions.clone(); let mut revisions = prepared.revisions.clone();
let mut blame = prepared.blame.clone();
let (left, before_label) = if branch { let (left, before_label) = if branch {
let base = self.merge_base(ids[0].as_deref().unwrap(), ids[1].as_deref().unwrap())?; 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}"); let label = format!("merge base of {label} and {after_label} · {base}");
revisions[0] = base.clone(); revisions[0] = base.clone();
blame[0] = self.blame_baseline(&RevisionTarget::Commit(base.clone()))?;
(Some(self.snapshot(&RevisionTarget::Commit(base))?), label) (Some(self.snapshot(&RevisionTarget::Commit(base))?), label)
} else if changed_before { } else if changed_before {
revisions[0] = ids[0].clone().unwrap(); 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()))?), Some(self.snapshot(&RevisionTarget::Commit(ids[0].clone().unwrap()))?),
label, label,
@@ -2699,6 +2754,13 @@ impl Repository {
ids[1].clone().unwrap() 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] let pinned_checkout = ids[1]
.as_deref() .as_deref()
.map(|id| self.pinned_checkout(id)) .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_before = std::mem::replace(&mut prepared.before, before_label);
let previous_editable = prepared.editable_after; let previous_editable = prepared.editable_after;
let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions); let previous_revisions = std::mem::replace(&mut prepared.revisions, revisions);
let previous_blame = std::mem::replace(&mut prepared.blame, blame);
prepared.editable_after = prepared.editable_after =
working_after || (pinned_checkout.is_some() && pinned_checkout == anchor); working_after || (pinned_checkout.is_some() && pinned_checkout == anchor);
let result = self let result = self
@@ -2747,6 +2810,7 @@ impl Repository {
prepared.before = previous_before; prepared.before = previous_before;
prepared.editable_after = previous_editable; prepared.editable_after = previous_editable;
prepared.revisions = previous_revisions; prepared.revisions = previous_revisions;
prepared.blame = previous_blame;
} }
self.comparison = Some(prepared); self.comparison = Some(prepared);
result result
@@ -2861,6 +2925,7 @@ impl Repository {
}; };
Comparison { Comparison {
revisions: prepared.revisions.clone(), revisions: prepared.revisions.clone(),
blame: prepared.blame.clone(),
before: prepared.before.clone(), before: prepared.before.clone(),
after: prepared.after.clone(), after: prepared.after.clone(),
files, files,
+1
View File
@@ -1690,6 +1690,7 @@ impl EditorSession {
self.blame_request_id, self.blame_request_id,
source.path.to_path_buf(), source.path.to_path_buf(),
self.editor.lines().join("\n"), self.editor.lines().join("\n"),
source.snapshot.as_deref().cloned(),
) )
.is_err() .is_err()
{ {
+34 -16
View File
@@ -2665,22 +2665,13 @@ fn draw_document(
.find(|(_, (row, _, _))| *row == cursor.row) .find(|(_, (row, _, _))| *row == cursor.row)
{ {
let line = &editor.lines()[cursor.row]; let line = &editor.lines()[cursor.row];
let end = display_column(line, usize::MAX); if !wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len() {
let start = end.saturating_sub(segment.start).saturating_add(2); paint_inline_blame(
let label_width = UnicodeWidthStr::width(label); frame,
if end >= segment.start label,
&& end <= segment.end line,
&& (!wrapped || continuation + 1 == document.wrap.segments(cursor.row, line).len()) Rect::new(viewport.x, viewport.y + screen_row as u16, width as u16, 1),
&& start.saturating_add(label_width) <= width segment.start,
{
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,
),
); );
} }
} }
@@ -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( fn paint_diff_editor(
frame: &mut Frame, frame: &mut Frame,
editor: &Editor, editor: &Editor,
@@ -3250,6 +3253,7 @@ fn paint_diff_editor(
let source_cursor = editor.cursor(); let source_cursor = editor.cursor();
let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column); let cursor_column = display_column(&editor.lines()[source_cursor.row], source_cursor.column);
let mut cursor = None; let mut cursor = None;
let mut blame_area = None;
let focus = view.info.focus; let focus = view.info.focus;
for (screen, display, segments) in for (screen, display, segments) in
diff_screen_rows(view.info.display_len, top, area.height, wrapped) 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)); 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 cursor
} }
+71 -1
View File
@@ -226,6 +226,13 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap(); .unwrap();
assert_eq!(first.revisions[0], id); assert_eq!(first.revisions[0], id);
assert!(first.revisions[1].starts_with("working:")); assert!(first.revisions[1].starts_with("working:"));
assert_eq!(first.blame[0].as_ref().unwrap().commit, id);
assert_eq!(
first.blame[1]
.as_ref()
.map(|baseline| baseline.commit.as_str()),
(kind == RepoKind::Git).then_some(id.as_str())
);
let reopened = open(&root) let reopened = open(&root)
.compare(2, &before, &after, ComparisonMode::Snapshots, &[]) .compare(2, &before, &after, ComparisonMode::Snapshots, &[])
.unwrap(); .unwrap();
@@ -255,6 +262,10 @@ fn comparison_revisions_freeze_commits_and_address_working_contents() {
.unwrap(); .unwrap();
assert_eq!(working_pair.revisions[0], first.revisions[1]); assert_eq!(working_pair.revisions[0], first.revisions[1]);
assert_eq!(working_pair.revisions[0], working_pair.revisions[1]); assert_eq!(working_pair.revisions[0], working_pair.revisions[1]);
assert_eq!(
working_pair.blame,
[first.blame[1].clone(), first.blame[1].clone()]
);
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
fs::write(root.join("file"), "working").unwrap(); fs::write(root.join("file"), "working").unwrap();
let no_newline = repository let no_newline = repository
@@ -785,6 +796,8 @@ fn jj_bookmark_refresh_tracks_movement_and_same_change_rewrites_without_touching
panic!("Bookmark movement must replace contents even when follow is disabled"); panic!("Bookmark movement must replace contents even when follow is disabled");
}; };
assert_eq!(comparison.after, topic.label); assert_eq!(comparison.after, topic.label);
assert!(comparison.blame[0].is_none());
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, next);
assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]); assert_eq!(repository.load_file(2, 0).unwrap().after, ["next"]);
assert!(repository.load_file(1, 0).is_err()); assert!(repository.load_file(1, 0).is_err());
assert!( assert!(
@@ -944,6 +957,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
.unwrap(); .unwrap();
assert!(original.before.ends_with(&base)); assert!(original.before.ends_with(&base));
assert_eq!(original.revisions, [base.clone(), tip.clone()]); assert_eq!(original.revisions, [base.clone(), tip.clone()]);
assert_eq!(original.blame[0].as_ref().unwrap().commit, base);
assert_eq!(original.blame[1].as_ref().unwrap().commit, tip);
jj(&root, &["bookmark", "set", "before", "-r", &tip]); jj(&root, &["bookmark", "set", "before", "-r", &tip]);
let Some(CheckoutRefresh::Bookmark(comparison)) = let Some(CheckoutRefresh::Bookmark(comparison)) =
repository.refresh_checkout(1, 2, false, &[]).unwrap() repository.refresh_checkout(1, 2, false, &[]).unwrap()
@@ -952,6 +967,8 @@ fn jj_bookmark_refresh_recomputes_branch_merge_base_and_recovers_after_deletion(
}; };
assert!(comparison.before.ends_with(&tip)); assert!(comparison.before.ends_with(&tip));
assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]); assert_eq!(comparison.revisions, [tip.clone(), tip.clone()]);
assert_eq!(comparison.blame[0].as_ref().unwrap().commit, tip);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, tip);
assert!(comparison.files.is_empty()); assert!(comparison.files.is_empty());
jj(&root, &["bookmark", "delete", "after"]); jj(&root, &["bookmark", "delete", "after"]);
let state = disk_state(&root); let state = disk_state(&root);
@@ -1331,6 +1348,7 @@ fn jj_private_fetch_discovers_never_fetched_bookmark_and_searchable_remote_chang
.unwrap(); .unwrap();
assert_eq!(promoted.before, branch.before); assert_eq!(promoted.before, branch.before);
assert_eq!(promoted.after, branch.after); assert_eq!(promoted.after, branch.after);
assert_eq!(promoted.blame, branch.blame);
assert!(promoted.editable_paths.contains(Path::new("file"))); assert!(promoted.editable_paths.contains(Path::new("file")));
assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]); assert_eq!(repository.load_file(62, 0).unwrap().after, ["remote tip"]);
assert_eq!(disk_state(&active), checked_out_state); assert_eq!(disk_state(&active), checked_out_state);
@@ -2260,6 +2278,8 @@ fn git_working_after_advances_only_to_descendants_with_new_paths_and_unsaved_tex
.unwrap(); .unwrap();
assert_eq!(refreshed.before, original.before); assert_eq!(refreshed.before, original.before);
assert_eq!(refreshed.after, original.after); assert_eq!(refreshed.after, original.after);
assert_eq!(refreshed.blame[0], original.blame[0]);
assert_eq!(refreshed.blame[1].as_ref().unwrap().commit, tip);
for (index, file) in refreshed.files.iter_mut().enumerate() { for (index, file) in refreshed.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap(); *file = repository.load_file(2, index).unwrap();
} }
@@ -2526,13 +2546,14 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
jj(&root, &["new"]); jj(&root, &["new"]);
fs::write(root.join("file"), "original disk\n").unwrap(); fs::write(root.join("file"), "original disk\n").unwrap();
let mut repository = open(&root); let mut repository = open(&root);
compare_loaded( let initial = compare_loaded(
&mut repository, &mut repository,
&revision(&base, RepoKind::Jujutsu), &revision(&base, RepoKind::Jujutsu),
&working(RepoKind::Jujutsu), &working(RepoKind::Jujutsu),
&[], &[],
) )
.unwrap(); .unwrap();
assert_eq!(initial.blame[1].as_ref().unwrap().commit, base);
fs::write(root.join("file"), "same-change rewrite\n").unwrap(); fs::write(root.join("file"), "same-change rewrite\n").unwrap();
jj(&root, &["describe", "-m", "rewritten"]); jj(&root, &["describe", "-m", "rewritten"]);
let state = disk_state(&root); let state = disk_state(&root);
@@ -2545,6 +2566,7 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]); assert_eq!(repository.load_file(1, 0).unwrap().after, ["original disk"]);
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
jj(&root, &["new"]); jj(&root, &["new"]);
let parent = jj(&root, &["log", "--no-graph", "-r", "@-", "-T", "commit_id"]);
fs::remove_file(root.join("removed")).unwrap(); fs::remove_file(root.join("removed")).unwrap();
fs::write(root.join("added"), "new file\n").unwrap(); fs::write(root.join("added"), "new file\n").unwrap();
let state = disk_state(&root); let state = disk_state(&root);
@@ -2553,6 +2575,8 @@ fn jj_working_after_follows_changes_not_rewrites_and_rejects_other_history() {
.unwrap() .unwrap()
.map(checkout_comparison) .map(checkout_comparison)
.unwrap(); .unwrap();
assert_eq!(comparison.blame[0], initial.blame[0]);
assert_eq!(comparison.blame[1].as_ref().unwrap().commit, parent);
for (index, file) in comparison.files.iter_mut().enumerate() { for (index, file) in comparison.files.iter_mut().enumerate() {
*file = repository.load_file(2, index).unwrap(); *file = repository.load_file(2, index).unwrap();
} }
@@ -2727,12 +2751,16 @@ fn branch_changes_resolves_local_and_private_remote_ancestry_without_active_muta
assert_eq!(branch.files.len(), 1); assert_eq!(branch.files.len(), 1);
assert_eq!(branch.files[0].path, Path::new("remote-only")); assert_eq!(branch.files[0].path, Path::new("remote-only"));
assert!(branch.before.ends_with(&base)); assert!(branch.before.ends_with(&base));
assert_eq!(branch.blame[0].as_ref().unwrap().commit, base);
assert!(!branch.blame[1].as_ref().unwrap().store.starts_with(&active));
assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]); assert_eq!(repository.load_file(40, 0).unwrap().after, ["remote"]);
let reverse = repository let reverse = repository
.compare(41, remote, &local, ComparisonMode::BranchChanges, &[]) .compare(41, remote, &local, ComparisonMode::BranchChanges, &[])
.unwrap(); .unwrap();
assert_eq!(reverse.files.len(), 1); assert_eq!(reverse.files.len(), 1);
assert_eq!(reverse.files[0].path, Path::new("local-only")); assert_eq!(reverse.files[0].path, Path::new("local-only"));
assert_eq!(reverse.blame[0].as_ref().unwrap().commit, base);
assert_eq!(reverse.blame[1].as_ref().unwrap().commit, local.label);
assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]); assert_eq!(repository.load_file(41, 0).unwrap().after, ["local"]);
assert!( assert!(
repository repository
@@ -2866,3 +2894,45 @@ fn pull_request_refs_fetch_pinned_fork_heads_privately_for_git_and_jj() {
assert_eq!(disk_state(&root), state); assert_eq!(disk_state(&root), state);
} }
} }
#[test]
fn jj_working_blame_captures_one_parent_and_never_selects_an_arbitrary_merge_parent() {
let fixture = Fixture::new();
let root = fixture.dir("blame-parents");
init_jj(&root, false);
fs::write(root.join("file"), "base\n").unwrap();
jj(&root, &["describe", "-m", "base"]);
let base = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new"]);
let state = disk_state(&root);
let mut repository = open(&root);
let one_parent = repository
.compare(
1,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(one_parent.blame[0].as_ref().unwrap().commit, base);
assert_eq!(one_parent.blame[0], one_parent.blame[1]);
assert_eq!(disk_state(&root), state);
jj(&root, &["new", "root()"]);
fs::write(root.join("other"), "sibling\n").unwrap();
jj(&root, &["describe", "-m", "sibling"]);
let sibling = jj(&root, &["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
jj(&root, &["new", &base, &sibling]);
let state = disk_state(&root);
let merged = repository
.compare(
2,
&working(RepoKind::Jujutsu),
&working(RepoKind::Jujutsu),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
assert_eq!(merged.blame, [None, None]);
assert_eq!(disk_state(&root), state);
}
+255 -2
View File
@@ -29,6 +29,26 @@ fn command(root: &Path, program: &str, args: &[&str]) -> String {
); );
String::from_utf8(output.stdout).unwrap().trim().into() String::from_utf8(output.stdout).unwrap().trim().into()
} }
fn disk_state(root: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
fn visit(root: &Path, path: &Path, state: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>) {
for entry in fs::read_dir(path).unwrap() {
let path = entry.unwrap().path();
let metadata = fs::symlink_metadata(&path).unwrap();
if metadata.is_dir() {
visit(root, &path, state);
} else if metadata.is_file() {
state.insert(
path.strip_prefix(root).unwrap().to_path_buf(),
fs::read(path).unwrap(),
);
}
}
}
let mut state = std::collections::BTreeMap::new();
visit(root, root, &mut state);
state
}
fn fixture() -> (Temporary, PathBuf, String) { fn fixture() -> (Temporary, PathBuf, String) {
let temp = Temporary::new().unwrap(); let temp = Temporary::new().unwrap();
command(&temp.0, "git", &["init", "--quiet", "--template="]); command(&temp.0, "git", &["init", "--quiet", "--template="]);
@@ -150,6 +170,239 @@ fn missing_untracked_unborn_nonrepo_and_symlink_have_no_blame() {
None None
); );
} }
#[test]
fn historical_git_and_jj_blame_survives_renamed_and_deleted_paths() {
for is_jj in [false, true] {
let (temp, old_path, original) = fixture();
if is_jj {
command(&temp.0, "jj", &["git", "init", "--colocate", "."]);
command(&temp.0, "jj", &["new", &original]);
}
fs::write(&old_path, "historical\nsecond\n").unwrap();
let selected = if is_jj {
command(
&temp.0,
"jj",
&["describe", "-m", "Selected historical lines"],
);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "."]);
command(
&temp.0,
"git",
&["commit", "--quiet", "-m", "Selected historical lines"],
);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
let renamed = temp.0.join("renamed.txt");
fs::rename(&old_path, &renamed).unwrap();
let renamed_commit = if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Rename"]);
let id = command(
&temp.0,
"jj",
&["log", "--no-graph", "-r", "@", "-T", "commit_id"],
);
command(&temp.0, "jj", &["new"]);
id
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Rename"]);
command(&temp.0, "git", &["rev-parse", "HEAD"])
};
fs::remove_file(&renamed).unwrap();
if is_jj {
command(&temp.0, "jj", &["describe", "-m", "Delete"]);
command(&temp.0, "jj", &["new"]);
} else {
command(&temp.0, "git", &["add", "-A"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Delete"]);
}
let store = if is_jj {
crate::repository::store_for_jj(&temp.0).unwrap()
} else {
temp.0.join(".git")
};
let state = disk_state(&temp.0);
for (commit, path) in [
(
selected.clone(),
old_path.strip_prefix(&temp.0).unwrap().to_path_buf(),
),
(renamed_commit, PathBuf::from("renamed.txt")),
] {
let source = RevisionSource {
store: store.clone(),
commit,
path,
keepalive: None,
};
let mut worker = BlameWorker::start().unwrap();
worker
.request(
1,
old_path.clone(),
"historical\nsecond\n".into(),
Some(source),
)
.unwrap();
let started = Instant::now();
let rows = loop {
if let Some((id, result)) = worker.poll() {
assert_eq!(id, 1);
break result.unwrap().unwrap();
}
assert!(
started.elapsed() < Duration::from_secs(20),
"Historical blame stalled"
);
thread::yield_now();
};
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(
rows[0].as_ref().unwrap().summary,
"Selected historical lines"
);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
}
assert!(!old_path.exists() && !renamed.exists());
assert_eq!(disk_state(&temp.0), state);
}
}
#[test]
fn privately_fetched_git_and_noncolocated_jj_sources_keep_selected_attribution() {
use crate::diff::{Category, ComparisonMode, Revision, RevisionTarget};
let (remote, remote_path, original) = fixture();
fs::write(&remote_path, "private historical\nsecond\n").unwrap();
command(&remote.0, "git", &["add", "."]);
command(
&remote.0,
"git",
&["commit", "--quiet", "-m", "Private selected"],
);
let selected = command(&remote.0, "git", &["rev-parse", "HEAD"]);
let reference = command(&remote.0, "git", &["symbolic-ref", "HEAD"]);
for is_jj in [false, true] {
let (active, active_path, _) = fixture();
fs::remove_file(&active_path).unwrap();
if is_jj {
fs::remove_dir_all(active.0.join(".git")).unwrap();
command(&active.0, "jj", &["git", "init", "--no-colocate", "."]);
command(
&active.0,
"jj",
&["git", "remote", "add", "origin", remote.0.to_str().unwrap()],
);
} else {
command(
&active.0,
"git",
&["remote", "add", "origin", remote.0.to_str().unwrap()],
);
}
let revision = |id: &str| Revision {
target: RevisionTarget::RemoteCommit {
remote: "origin".into(),
reference: reference.clone(),
id: id.into(),
},
label: id.into(),
description: String::new(),
category: Category::Commits,
};
let state = disk_state(&active.0);
let mut repository =
crate::repository::Repository::open(&active.0, Arc::new(AtomicBool::new(false)))
.unwrap();
let comparison = repository
.compare(
1,
&revision(&original),
&revision(&selected),
ComparisonMode::Snapshots,
&[],
)
.unwrap();
let baseline = comparison.blame[1].as_ref().unwrap();
assert_eq!(baseline.commit, selected);
assert!(!baseline.store.starts_with(&active.0));
let source = RevisionSource {
store: baseline.store.clone(),
commit: baseline.commit.clone(),
path: remote_path.strip_prefix(&remote.0).unwrap().to_path_buf(),
keepalive: baseline.keepalive.clone(),
};
let store = source.store.clone();
drop(comparison);
drop(repository);
assert!(
store.exists(),
"Snapshot provenance must retain private fetched objects"
);
let rows = read_revision(&source, "private historical\nsecond\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(rows[0].as_ref().unwrap().commit, selected);
assert_eq!(rows[1].as_ref().unwrap().commit, original);
assert!(!rows[0].as_ref().unwrap().uncommitted);
assert_eq!(disk_state(&active.0), state);
drop(source);
assert!(
!store.exists(),
"Last snapshot release must remove private objects"
);
}
}
#[test]
fn revision_sources_reject_unsafe_paths_and_skip_missing_or_nonregular_baselines() {
let (temp, path, commit) = fixture();
let mut source = RevisionSource {
store: temp.0.join(".git"),
commit,
path: "not-in-baseline".into(),
keepalive: None,
};
assert_eq!(read_revision(&source, "new\n", &|| false).unwrap(), None);
for path in ["", "../file", "/outside", ".git/config", ".jj/repo"] {
source.path = path.into();
assert!(read_revision(&source, "new\n", &|| false).is_err());
}
std::os::unix::fs::symlink(&path, temp.0.join("link")).unwrap();
fs::create_dir(temp.0.join("nested")).unwrap();
fs::write(temp.0.join("nested/file"), "nested\n").unwrap();
command(&temp.0, "git", &["add", "link", "nested/file"]);
command(&temp.0, "git", &["commit", "--quiet", "-m", "Link"]);
source.commit = command(&temp.0, "git", &["rev-parse", "HEAD"]);
source.path = "link".into();
assert_eq!(read_revision(&source, "target\n", &|| false).unwrap(), None);
source.path = "nested".into();
assert_eq!(read_revision(&source, "nested\n", &|| false).unwrap(), None);
source.path = "nested/file".into();
let nested = read_revision(&source, "nested\n", &|| false)
.unwrap()
.unwrap();
assert_eq!(nested[0].as_ref().unwrap().commit, source.commit);
source.path = path.strip_prefix(&temp.0).unwrap().into();
assert_eq!(
read_revision(&source, "first\n", &|| true).unwrap_err(),
"Blame cancelled"
);
assert_eq!(
read_revision(&source, &"x".repeat(LIMIT + 1), &|| false).unwrap_err(),
"Blame input exceeds 16 MiB"
);
}
#[test] #[test]
fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() { fn noncolocated_jj_uses_parent_without_snapshotting_or_changing_refs() {
let temp = Temporary::new().unwrap(); let temp = Temporary::new().unwrap();
@@ -332,7 +585,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
let mut worker = BlameWorker::start().unwrap(); let mut worker = BlameWorker::start().unwrap();
for id in 0..20 { for id in 0..20 {
worker worker
.request(id, path.clone(), "first\nsecond\n".into()) .request(id, path.clone(), "first\nsecond\n".into(), None)
.unwrap(); .unwrap();
} }
loop { loop {
@@ -351,7 +604,7 @@ fn worker_keeps_only_latest_request_and_bounds_input() {
} }
assert_eq!( assert_eq!(
worker worker
.request(20, path, "x".repeat(LIMIT + 1)) .request(20, path, "x".repeat(LIMIT + 1), None)
.unwrap_err() .unwrap_err()
.kind(), .kind(),
io::ErrorKind::InvalidInput io::ErrorKind::InvalidInput
+3
View File
@@ -780,6 +780,7 @@ fn choose_pair(editor: &mut Editor) -> u64 {
fn comparison() -> Comparison { fn comparison() -> Comparison {
Comparison { Comparison {
blame: Default::default(),
revisions: ["abcdef123".into(), "working:snapshot".into()], revisions: ["abcdef123".into(), "working:snapshot".into()],
before: "abcdef123".into(), before: "abcdef123".into(),
after: "Working checkout".into(), after: "Working checkout".into(),
@@ -1774,6 +1775,7 @@ fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() {
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
id, id,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["working:snapshot".into(), "working:snapshot".into()], revisions: ["working:snapshot".into(), "working:snapshot".into()],
before: "Working".into(), before: "Working".into(),
after: "Working".into(), after: "Working".into(),
@@ -1806,6 +1808,7 @@ fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
Category::Commits, Category::Commits,
)); ));
state.comparison = Some(Comparison { state.comparison = Some(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()], revisions: ["base".into(), "working:snapshot".into()],
root: PathBuf::new(), root: PathBuf::new(),
before: "base".into(), before: "base".into(),
+5
View File
@@ -1879,6 +1879,7 @@ fn hidden_tab_diff_results_restore_visible_insert_interaction_and_route_request_
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
comparison_request, comparison_request,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: editor.project_root().to_owned(), root: editor.project_root().to_owned(),
revisions: ["base".into(), "working-snapshot".into()], revisions: ["base".into(), "working-snapshot".into()],
before: "base".into(), before: "base".into(),
@@ -3068,6 +3069,7 @@ fn working_diff(editor: &mut Editor, root: &Path, paths: &[&str]) -> Box<[(PathB
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
id, id,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: root.canonicalize().unwrap(), root: root.canonicalize().unwrap(),
revisions: ["base".into(), "working-snapshot".into()], revisions: ["base".into(), "working-snapshot".into()],
before: "base".into(), before: "base".into(),
@@ -3105,6 +3107,7 @@ fn historical_diff(editor: &mut Editor, root: &Path, before: Vec<String>, after:
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(Outcome::Comparison(Comparison { Ok(Outcome::Comparison(Comparison {
blame: Default::default(),
root: root.to_owned(), root: root.to_owned(),
revisions: ["base".into(), "head".into()], revisions: ["base".into(), "head".into()],
before: "base".into(), before: "base".into(),
@@ -4108,6 +4111,7 @@ fn bookmark_refresh_reloads_paths_and_preserves_view_until_lazy_content_arrives(
assert!(editor.apply_diff_result( assert!(editor.apply_diff_result(
refresh, refresh,
Ok(Outcome::BookmarkRefresh(Comparison { Ok(Outcome::BookmarkRefresh(Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
revisions: ["base".into(), "topic-snapshot".into()], revisions: ["base".into(), "topic-snapshot".into()],
before: "base".into(), before: "base".into(),
@@ -4229,6 +4233,7 @@ fn completed_working_refresh_serializes_lazy_selection_and_invalidates_pending_n
let loaded_after = target.after.clone(); let loaded_after = target.after.clone();
target.loaded = false; target.loaded = false;
let replacement = crate::diff::Comparison { let replacement = crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
revisions: ["base".into(), "ahead-snapshot".into()], revisions: ["base".into(), "ahead-snapshot".into()],
before: "base".into(), before: "base".into(),
+2
View File
@@ -588,6 +588,7 @@ fn checkout_diff_polling_uses_injected_interval_independently_of_endpoint_follow
session.editor.apply_diff_result( session.editor.apply_diff_result(
id, id,
Ok(DiffOutcome::Comparison(Comparison { Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "after".into()], revisions: ["base".into(), "after".into()],
root: fixture.0.clone(), root: fixture.0.clone(),
before: "base".into(), before: "base".into(),
@@ -3721,6 +3722,7 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
session.editor.apply_diff_result( session.editor.apply_diff_result(
id, id,
Ok(DiffOutcome::Comparison(Comparison { Ok(DiffOutcome::Comparison(Comparison {
blame: Default::default(),
revisions: ["base".into(), "working:snapshot".into()], revisions: ["base".into(), "working:snapshot".into()],
root: fixture.0.canonicalize().unwrap(), root: fixture.0.canonicalize().unwrap(),
before: "base".into(), before: "base".into(),
+227 -5
View File
@@ -2593,6 +2593,7 @@ fn diff_comparison(files: Vec<ChangedFile>) -> DiffState {
diff.apply_result( diff.apply_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: std::path::PathBuf::new(), root: std::path::PathBuf::new(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "old revision".into(), before: "old revision".into(),
@@ -4805,6 +4806,7 @@ fn immutable_diff_draw_path_preserves_document_until_explicit_close() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: std::path::PathBuf::new(), root: std::path::PathBuf::new(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "old revision".into(), before: "old revision".into(),
@@ -5652,6 +5654,7 @@ fn comparison_titles_follow_loaded_counts_and_each_tabs_context() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -5745,6 +5748,7 @@ fn comparison_title_counts_follow_live_edits_and_undo_in_hidden_tabs() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: ["live.rs".into()].into(), editable_paths: ["live.rs".into()].into(),
before: "baseline".into(), before: "baseline".into(),
@@ -6377,6 +6381,7 @@ fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -6455,6 +6460,218 @@ fn narrow_tab_bar_keeps_the_active_title_visible_and_handles_empty_areas() {
assert!(titles.contains("2 [No Name]"), "{titles}"); assert!(titles.contains("2 [No Name]"), "{titles}");
} }
#[test]
fn inline_blame_is_visible_in_focused_diff_buffers() {
for unified in [false, true] {
for before in [false, true] {
let mut editor = readonly_diff_editor(deletion_file());
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
if editor.diff_buffer_focus() != Some(if before { Focus::Before } else { Focus::After })
{
editor.handle(Input::Char('\t'));
}
let source = editor.blame_source().unwrap();
let blame = Arc::new(crate::blame::LineBlame {
commit: "0123456789abcdef".into(),
author: "Author".into(),
summary: "Original".into(),
uncommitted: false,
});
editor.set_line_blame(source.clone(), blame.clone());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let screen = screen_text(&terminal);
assert!(screen.contains("Author · Original (01234567)"), "{screen}");
let caret = terminal.get_cursor_position().unwrap();
assert!(pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Author"));
editor.handle(Input::Char('\t'));
assert!(!editor.set_line_blame(source, blame));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(!screen_text(&terminal).contains("Author · Original"));
}
}
}
#[test]
fn diff_blame_uses_final_wrap_and_survives_a_historical_tab() {
for unified in [false, true] {
let text = "x".repeat(200);
let mut editor = readonly_diff_editor(syntax_diff_file("file.txt", &text, "after"));
if unified {
editor.execute("diff.view.toggle");
}
editor.handle(Input::Char('\t'));
editor.execute("word_wrap.toggle");
let source = editor.blame_source().unwrap();
let blame = Arc::new(crate::blame::LineBlame {
commit: String::new(),
author: String::new(),
summary: String::new(),
uncommitted: true,
});
editor.set_line_blame(source.clone(), blame.clone());
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 14)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let caret = terminal.get_cursor_position().unwrap();
let screen = screen_text(&terminal);
assert_eq!(screen.matches("Uncommitted").count(), 1, "{screen}");
assert!(!pane_screen(&terminal, Rect::new(0, caret.y, 180, 1)).contains("Uncommitted"));
let mut narrow = Terminal::new(ratatui::backend::TestBackend::new(12, 14)).unwrap();
narrow.draw(|frame| views.draw(frame, &mut editor)).unwrap();
assert!(!screen_text(&narrow).contains("Uncommitted"));
editor.execute("tab.new");
assert!(editor.diff().is_none());
if !editor.word_wrap() {
editor.execute("word_wrap.toggle");
}
editor.set_cached_line_blame(&source, blame);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Uncommitted"));
editor.handle(Input::Char('i'));
editor.handle(Input::Char('z'));
assert_eq!(editor.lines().join("\n"), text);
}
}
#[test]
fn inline_blame_loads_real_historical_and_working_diff_sources() {
let directory = Directory::new();
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(&directory.0)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "Author")
.env("GIT_AUTHOR_EMAIL", "author@example.test")
.env("GIT_COMMITTER_NAME", "Author")
.env("GIT_COMMITTER_EMAIL", "author@example.test")
.env("GIT_AUTHOR_DATE", "2001-01-01T00:00:00Z")
.env("GIT_COMMITTER_DATE", "2001-01-01T00:00:00Z")
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_owned()
};
git(&["init", "-q", "--template="]);
let path = directory.file("file.txt", "original\n");
git(&["add", "file.txt"]);
git(&["commit", "-qm", "Original"]);
let original = git(&["rev-parse", "HEAD"]);
directory.file("file.txt", "historical\n");
git(&["commit", "-qam", "Historical"]);
let historical = git(&["rev-parse", "HEAD"]);
git(&["rm", "-q", "file.txt"]);
git(&["commit", "-qm", "Deleted"]);
let config = directory.file(
".git/vamp-config.rb",
"Vamp.configure { |c| c.git_gutter = 'none' }",
);
let mut session = EditorSession::open(Some(path.clone()), vec![], Some(&config)).unwrap();
let ready = |session: &mut EditorSession, predicate: &dyn Fn(&Editor) -> bool| {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !predicate(session.editor()) {
assert!(
std::time::Instant::now() < deadline,
"message={:?}, state={:?}, blame={:?}, text={:?}",
session.editor().message(),
session.editor().diff().map(|diff| (
diff.phase,
diff.focus,
diff.file,
&diff.error
)),
session.editor().line_blame(),
session.editor().lines()
);
session.poll();
std::thread::sleep(Duration::from_millis(1));
}
};
session.execute("diff");
ready(&mut session, &|editor| {
editor.diff().is_some_and(|diff| diff.catalog.is_some())
});
for revision in [&original, &historical] {
for ch in revision.chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
session.handle(Input::Enter);
}
ready(&mut session, &|editor| {
editor
.diff()
.is_some_and(|diff| diff.selected_file_loaded())
});
session.handle(Input::Char('\t'));
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(180, 12)).unwrap();
let mut views = BufferViewports::default();
for expected in ["Original", "Historical"] {
ready(&mut session, &|editor| {
editor
.line_blame()
.is_some_and(|label| label.contains(expected))
});
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
let screen = screen_text(&terminal);
assert!(screen.contains(&format!("Author · {expected}")), "{screen}");
assert!(!screen.contains("Uncommitted"), "{screen}");
session.handle(Input::Char('\t'));
}
assert!(!path.exists());
session.execute("diff.close");
directory.file("file.txt", "restored\n");
git(&["add", "file.txt"]);
git(&["commit", "-qm", "Restored"]);
directory.file("file.txt", "unsaved baseline\n");
session.editor_mut().refresh_files();
session.execute("diff");
ready(&mut session, &|editor| {
editor.diff().is_some_and(|diff| diff.catalog.is_some())
});
for ch in git(&["rev-parse", "HEAD"]).chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Down);
session.handle(Input::Enter);
session.handle(Input::Enter);
ready(&mut session, &|editor| {
editor
.diff()
.is_some_and(|diff| diff.selected_file_loaded())
});
session.handle(Input::Char('\t'));
session.handle(Input::Char('\t'));
ready(&mut session, &|editor| {
editor.line_blame() == Some("Uncommitted")
});
terminal
.draw(|frame| views.draw_session(frame, &mut session))
.unwrap();
assert!(screen_text(&terminal).contains("Uncommitted"));
assert_eq!(fs::read_to_string(path).unwrap(), "unsaved baseline\n");
}
#[test] #[test]
fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() { fn inline_blame_sanitizes_metadata_preserves_text_and_hides_stale_labels() {
let directory = Directory::new(); let directory = Directory::new();
@@ -6545,11 +6762,6 @@ fn inline_blame_uses_last_wrap_continuation_and_never_overflows_narrow_panes() {
.draw(|frame| views.draw(frame, &mut editor)) .draw(|frame| views.draw(frame, &mut editor))
.unwrap(); .unwrap();
assert!(!screen_text(&terminal).contains("Uncommitted")); assert!(!screen_text(&terminal).contains("Uncommitted"));
let mut historical = readonly_diff_editor(deletion_file());
assert!(historical.blame_source().is_none());
historical.execute("tab.new");
assert!(historical.diff().is_none());
assert!(historical.blame_source().is_none());
} }
#[test] #[test]
@@ -7782,6 +7994,7 @@ fn deferred_lsp_definition_centers_only_successful_current_diff_results() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -7880,6 +8093,13 @@ fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: std::array::from_fn(|_| {
Some(crate::diff::BlameRevision {
store: editor.project_root().join(".git"),
commit: "0123456789abcdef0123456789abcdef01234567".into(),
keepalive: None,
})
}),
root: editor.project_root().to_path_buf(), root: editor.project_root().to_path_buf(),
editable_paths: Default::default(), editable_paths: Default::default(),
before: "baseline".into(), before: "baseline".into(),
@@ -8732,6 +8952,7 @@ fn diff_semantic_colors_stay_buffer_local_across_focus_changes() {
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: if writable { editable_paths: if writable {
[std::path::PathBuf::from("live.rs")].into() [std::path::PathBuf::from("live.rs")].into()
@@ -8862,6 +9083,7 @@ fn editable_after_renders_live_syntax_caret_visual_selection_and_command_prompt(
editor.apply_diff_result( editor.apply_diff_result(
id, id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison { Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
blame: Default::default(),
root: directory.0.clone(), root: directory.0.clone(),
editable_paths: [std::path::PathBuf::from("live.rs")].into(), editable_paths: [std::path::PathBuf::from("live.rs")].into(),
before: "baseline".into(), before: "baseline".into(),