use std::{borrow::Borrow, collections::HashSet, io::Write, mem, ops::Range, pin::Pin};
use anyhow::{Result, anyhow};
use futures_util::{Stream, StreamExt, try_join};
use gix::bstr::ByteVec;
use itertools::Itertools;
use jj_cli::diff_util::LineDiffOptions;
use jj_lib::{
backend::CommitId,
conflict_labels::ConflictLabels,
conflicts::{
self, ConflictMarkerStyle, ConflictMaterializeOptions, MaterializedFileValue,
MaterializedTreeValue,
},
diff::{
CompareBytesExactly, CompareBytesIgnoreAllWhitespace, CompareBytesIgnoreWhitespaceAmount,
ContentDiff, DiffHunk, DiffHunkKind, find_line_ranges,
},
diff_presentation::LineCompareMode,
files::FileMergeHunkLevel,
graph::{GraphEdgeType, GraphNode, TopoGroupedGraph},
matchers::EverythingMatcher,
merge::{Diff, SameChange},
merged_tree::{TreeDiffEntry, TreeDiffStream},
ref_name::{RefNameBuf, RemoteNameBuf, RemoteRefSymbol},
repo::Repo,
repo_path::RepoPath,
revset::{Revset, RevsetEvaluationError},
rewrite,
tree_merge::MergeOptions,
};
use pollster::FutureExt as _;
use crate::messages::{
ChangeHunk, ChangeLocation, ChangeRange, MultilineString, RevSet, queries::*,
};
#[cfg(test)]
use crate::messages::{RevHeader, RevId};
use super::{WorkspaceSession, git_util::get_git_remote_names};
struct LogStem {
source: LogCoordinates,
target: CommitId,
indirect: bool,
was_inserted: bool,
known_immutable: bool,
}
pub struct QueryState {
page_size: usize,
next_row: usize,
stems: Vec<Option<LogStem>>,
}
impl QueryState {
pub fn new(page_size: usize) -> QueryState {
QueryState {
page_size,
next_row: 0,
stems: Vec::new(),
}
}
}
pub struct QuerySession<'q, 'w: 'q> {
pub ws: &'q WorkspaceSession<'w>,
pub state: QueryState,
#[allow(clippy::type_complexity)]
stream: Pin<Box<dyn Stream<Item = Result<GraphNode<CommitId>, RevsetEvaluationError>> + 'q>>,
lookahead: Option<Result<GraphNode<CommitId>, RevsetEvaluationError>>,
stream_done: bool,
#[allow(clippy::type_complexity)]
is_immutable: Box<dyn Fn(&CommitId) -> Result<bool, RevsetEvaluationError> + 'q>,
}
impl<'q, 'w> QuerySession<'q, 'w> {
pub fn new(
ws: &'q WorkspaceSession<'w>,
revset: &'q dyn Revset,
state: QueryState,
) -> QuerySession<'q, 'w> {
let as_id: for<'a> fn(&'a CommitId) -> &'a CommitId = commit_id_identity;
let mut stream: Pin<Box<dyn Stream<Item = _> + 'q>> =
Box::pin(TopoGroupedGraph::new(revset.stream_graph(), as_id).stream());
let mut stream_done = false;
for _ in 0..state.next_row {
match stream.next().block_on() {
Some(_) => {}
None => {
stream_done = true;
break;
}
}
}
let immutable_revset = ws.evaluate_immutable().unwrap();
let is_immutable = immutable_revset.containing_fn();
QuerySession {
ws,
stream,
lookahead: None,
stream_done,
state,
is_immutable,
}
}
fn next_node(&mut self) -> Option<Result<GraphNode<CommitId>, RevsetEvaluationError>> {
if let Some(item) = self.lookahead.take() {
return Some(item);
}
if self.stream_done {
return None;
}
match self.stream.next().block_on() {
Some(item) => Some(item),
None => {
self.stream_done = true;
None
}
}
}
fn has_more(&mut self) -> bool {
if self.lookahead.is_some() {
return true;
}
if self.stream_done {
return false;
}
match self.stream.next().block_on() {
Some(item) => {
self.lookahead = Some(item);
true
}
None => {
self.stream_done = true;
false
}
}
}
pub fn get_page(&mut self) -> Result<LogPage> {
let mut rows: Vec<LogRow> = Vec::with_capacity(self.state.page_size); let mut row = self.state.next_row;
let max = row + self.state.page_size;
let root_id = self.ws.repo().store().root_commit_id().clone();
while let Some(Ok((commit_id, commit_edges))) = self.next_node() {
let mut lines: Vec<LogLine> = Vec::new();
let mut column = self.state.stems.len();
let mut stem_known_immutable = false;
let mut padding = 0;
if let Some(slot) = self.find_stem_for_commit(&commit_id) {
column = slot;
padding = self.state.stems.len() - column - 1;
}
if column < self.state.stems.len() {
if let Some(terminated_stem) = &self.state.stems[column] {
stem_known_immutable = terminated_stem.known_immutable;
lines.push(if terminated_stem.was_inserted {
LogLine::FromNode {
indirect: terminated_stem.indirect,
source: terminated_stem.source,
target: LogCoordinates(column, row),
}
} else {
LogLine::ToNode {
indirect: terminated_stem.indirect,
source: terminated_stem.source,
target: LogCoordinates(column, row),
}
});
}
self.state.stems[column] = None;
}
else {
for (slot, stem) in self.state.stems.iter().enumerate() {
if stem.is_none() {
column = slot;
padding = self.state.stems.len() - slot - 1;
break;
}
}
}
let known_immutable = if stem_known_immutable {
Some(true)
} else {
Some((self.is_immutable)(&commit_id)?)
};
let header = self
.ws
.format_header(&self.ws.get_commit(&commit_id)?, known_immutable)?;
let empty_stems = self
.state
.stems
.iter()
.rev()
.take_while(|stem| stem.is_none())
.count();
self.state
.stems
.truncate(self.state.stems.len() - empty_stems);
let mut next_missing: Option<CommitId> = None;
'edges: for edge in commit_edges.iter() {
if edge.edge_type == GraphEdgeType::Missing {
if edge.target == root_id {
continue;
} else {
next_missing = Some(edge.target.clone());
}
}
let indirect = edge.edge_type != GraphEdgeType::Direct;
for (slot, stem) in self.state.stems.iter().enumerate() {
if let Some(stem) = stem
&& stem.target == edge.target
{
lines.push(LogLine::ToIntersection {
indirect,
source: LogCoordinates(column, row),
target: LogCoordinates(slot, row + 1),
});
continue 'edges;
}
}
for stem in self.state.stems.iter_mut() {
if stem.is_none() {
*stem = Some(LogStem {
source: LogCoordinates(column, row),
target: edge.target.clone(),
indirect,
was_inserted: true,
known_immutable: header.is_immutable,
});
continue 'edges;
}
}
self.state.stems.push(Some(LogStem {
source: LogCoordinates(column, row),
target: edge.target.clone(),
indirect,
was_inserted: false,
known_immutable: header.is_immutable,
}));
}
rows.push(LogRow {
revision: header,
location: LogCoordinates(column, row),
padding,
lines,
});
row += 1;
if let Some(slot) = next_missing
.take()
.and_then(|id| self.find_stem_for_commit(&id))
{
if let Some(terminated_stem) = &self.state.stems[slot] {
rows.last_mut().unwrap().lines.push(LogLine::ToMissing {
indirect: terminated_stem.indirect,
source: LogCoordinates(column, row - 1),
target: LogCoordinates(slot, row),
});
}
self.state.stems[slot] = None;
row += 1;
};
if row == max {
break;
}
}
self.state.next_row = row;
let has_more = self.has_more();
Ok(LogPage { rows, has_more })
}
fn find_stem_for_commit(&self, id: &CommitId) -> Option<usize> {
for (slot, stem) in self.state.stems.iter().enumerate() {
if let Some(LogStem { target, .. }) = stem
&& target == id
{
return Some(slot);
}
}
None
}
}
#[cfg(test)]
pub fn query_log(ws: &WorkspaceSession, revset_str: &str, max_results: usize) -> Result<LogPage> {
let state = QueryState::new(max_results);
let revset = ws.evaluate_revset_str(revset_str)?;
let mut session = QuerySession::new(ws, &*revset, state);
session.get_page()
}
#[cfg(test)]
pub fn query_revision(ws: &WorkspaceSession<'_>, id: &RevId) -> Result<Option<RevHeader>> {
ws.resolve_optional_id(id)?
.map(|c| ws.format_header(&c, None))
.transpose()
}
pub async fn query_revisions(ws: &WorkspaceSession<'_>, set: RevSet) -> Result<RevsResult> {
let commits = if set.from.change.hex == set.to.change.hex {
match ws.resolve_optional_id(&set.from)? {
Some(commit) => vec![commit],
None => return Ok(RevsResult::NotFound { set }),
}
} else {
match ws.resolve_optional_set(&set)? {
Some(commits) => commits,
None => return Ok(RevsResult::NotFound { set }),
}
};
let oldest_commit = commits.last().ok_or(anyhow!("slice is_empty()"))?;
let oldest_parents = oldest_commit.parents().await?;
let parent_tree = rewrite::merge_commit_trees(ws.repo(), &oldest_parents).await?;
let newest_commit = commits.first().ok_or(anyhow!("slice is_empty()"))?;
let final_tree = newest_commit.tree();
let mut changes = Vec::new();
let tree_diff = parent_tree.diff_stream(&final_tree, &EverythingMatcher);
let conflict_labels = Diff::new(parent_tree.labels(), final_tree.labels());
format_tree_changes(ws, &mut changes, tree_diff, conflict_labels).await?;
let mut conflicts = Vec::new();
for (path, entry) in final_tree.entries() {
if let Ok(entry) = entry
&& !entry.is_resolved()
{
match conflicts::materialize_tree_value(
ws.repo().store(),
&path,
entry,
final_tree.labels(),
)
.await?
{
MaterializedTreeValue::FileConflict(file) => {
let mut hunk_content = vec![];
conflicts::materialize_merge_result(
&file.contents,
&file.labels,
&mut hunk_content,
&ConflictMaterializeOptions {
marker_style: ConflictMarkerStyle::Git,
marker_len: None,
merge: MergeOptions {
hunk_level: FileMergeHunkLevel::Line,
same_change: SameChange::Accept,
},
},
)?;
let mut hunks = get_unified_hunks(3, &hunk_content, &[])?;
if let Some(hunk) = hunks.pop() {
conflicts.push(RevConflict {
path: ws.format_path(path)?,
hunk,
});
}
}
_ => {
log::warn!("nonresolved tree entry did not materialise as conflict");
}
}
}
}
let conflicted_paths: HashSet<String> = conflicts
.iter()
.map(|conflict| conflict.path.repo_path.clone())
.collect();
changes.retain(|change| !conflicted_paths.contains(&change.path.repo_path));
let mut headers = Vec::new();
let mut known_immutable: Option<bool> = None;
for commit in &commits {
let header = ws.format_header(commit, known_immutable)?;
if known_immutable.is_none() && header.is_immutable {
known_immutable = Some(true);
}
headers.push(header);
}
let parents = oldest_commit
.parents()
.await?
.iter()
.map(|p| ws.format_header(p, known_immutable))
.collect::<Result<Vec<_>, _>>()?;
Ok(RevsResult::Detail {
set,
headers,
parents,
changes,
conflicts,
})
}
pub fn query_remotes(
ws: &WorkspaceSession,
tracking_bookmark: Option<String>,
) -> Result<Vec<String>> {
let git_repo = match ws.git_repo() {
Some(git_repo) => git_repo,
None => return Err(anyhow!("No git backend")),
};
let all_remotes = get_git_remote_names(&git_repo);
let matching_remotes = match tracking_bookmark {
Some(bookmark_name) => all_remotes
.into_iter()
.filter(|remote_name| {
let remote_name_ref = RemoteNameBuf::from(remote_name);
let bookmark_name_ref = RefNameBuf::from(bookmark_name.clone());
let remote_ref_symbol = RemoteRefSymbol {
name: &bookmark_name_ref,
remote: &remote_name_ref,
};
let remote_ref = ws.view().get_remote_bookmark(remote_ref_symbol);
!remote_ref.is_absent() && remote_ref.is_tracked()
})
.collect(),
None => all_remotes,
};
Ok(matching_remotes)
}
async fn format_tree_changes(
ws: &WorkspaceSession<'_>,
changes: &mut Vec<RevChange>,
mut tree_diff: TreeDiffStream<'_>,
conflict_labels: Diff<&ConflictLabels>,
) -> Result<()> {
let store = ws.repo().store();
while let Some(TreeDiffEntry { path, values }) = tree_diff.next().await {
let diff = values?;
let before = &diff.before;
let after = &diff.after;
let kind = if before.is_present() && after.is_present() {
ChangeKind::Modified
} else if before.is_absent() {
ChangeKind::Added
} else {
ChangeKind::Deleted
};
let has_conflict = !after.is_resolved();
let before_future =
conflicts::materialize_tree_value(store, &path, before.clone(), conflict_labels.before);
let after_future =
conflicts::materialize_tree_value(store, &path, after.clone(), conflict_labels.after);
let (before_value, after_value) = try_join!(before_future, after_future)?;
let hunks = get_value_hunks(3, &path, before_value, after_value).await?;
changes.push(RevChange {
path: ws.format_path(path)?,
kind,
has_conflict,
hunks,
});
}
Ok(())
}
async fn get_value_hunks(
num_context_lines: usize,
path: &RepoPath,
left_value: MaterializedTreeValue,
right_value: MaterializedTreeValue,
) -> Result<Vec<ChangeHunk>> {
if left_value.is_absent() {
let right_part = get_value_contents(path, right_value).await?;
get_unified_hunks(num_context_lines, &[], &right_part)
} else if right_value.is_present() {
let left_part = get_value_contents(path, left_value).await?;
let right_part = get_value_contents(path, right_value).await?;
get_unified_hunks(num_context_lines, &left_part, &right_part)
} else {
let left_part = get_value_contents(path, left_value).await?;
get_unified_hunks(num_context_lines, &left_part, &[])
}
}
async fn get_value_contents(path: &RepoPath, value: MaterializedTreeValue) -> Result<Vec<u8>> {
use tokio::io::AsyncReadExt;
match value {
MaterializedTreeValue::Absent => Err(anyhow!(
"Absent path {path:?} in diff should have been handled by caller"
)),
MaterializedTreeValue::File(MaterializedFileValue { mut reader, .. }) => {
let mut contents = vec![];
reader.read_to_end(&mut contents).await?;
let start = &contents[..8000.min(contents.len())]; let is_binary = start.contains(&b'\0');
if is_binary {
contents.clear();
contents.push_str("(binary)");
}
Ok(contents)
}
MaterializedTreeValue::Symlink { target, .. } => Ok(target.into_bytes()),
MaterializedTreeValue::GitSubmodule(_) => Ok("(submodule)".to_owned().into_bytes()),
MaterializedTreeValue::FileConflict(file) => {
let mut hunk_content = vec![];
conflicts::materialize_merge_result(
&file.contents,
&file.labels,
&mut hunk_content,
&ConflictMaterializeOptions {
marker_style: ConflictMarkerStyle::Git,
marker_len: None,
merge: MergeOptions {
hunk_level: FileMergeHunkLevel::Line,
same_change: SameChange::Accept,
},
},
)?;
Ok(hunk_content)
}
MaterializedTreeValue::OtherConflict { id, labels } => {
Ok(id.describe(&labels).into_bytes())
}
MaterializedTreeValue::Tree(_) => Err(anyhow!("Unexpected tree in diff at path {path:?}")),
MaterializedTreeValue::AccessDenied(error) => Err(anyhow!(error)),
}
}
fn get_unified_hunks(
num_context_lines: usize,
left_content: &[u8],
right_content: &[u8],
) -> Result<Vec<ChangeHunk>> {
let mut hunks = Vec::new();
for hunk in unified_diff_hunks(
left_content,
right_content,
&UnifiedDiffOptions {
context: num_context_lines,
line_diff: LineDiffOptions {
compare_mode: LineCompareMode::Exact,
},
},
) {
let location = ChangeLocation {
from_file: ChangeRange {
start: hunk.left_line_range.start,
len: hunk.left_line_range.len(),
},
to_file: ChangeRange {
start: hunk.right_line_range.start,
len: hunk.right_line_range.len(),
},
};
let mut lines = Vec::new();
for (line_type, tokens) in hunk.lines {
let mut formatter: Vec<u8> = vec![];
match line_type {
DiffLineType::Context => {
write!(formatter, " ")?;
}
DiffLineType::Removed => {
write!(formatter, "-")?;
}
DiffLineType::Added => {
write!(formatter, "+")?;
}
}
for (token_type, content) in tokens {
match token_type {
DiffTokenType::Matching => formatter.write_all(content)?,
DiffTokenType::Different => formatter.write_all(content)?, }
}
lines.push(String::from_utf8_lossy(&formatter).into_owned());
}
hunks.push(ChangeHunk {
location,
lines: MultilineString { lines },
});
}
Ok(hunks)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnifiedDiffOptions {
pub context: usize,
pub line_diff: LineDiffOptions,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DiffLineType {
Context,
Removed,
Added,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DiffTokenType {
Matching,
Different,
}
type DiffTokenVec<'content> = Vec<(DiffTokenType, &'content [u8])>;
struct UnifiedDiffHunk<'content> {
left_line_range: Range<usize>,
right_line_range: Range<usize>,
lines: Vec<(DiffLineType, DiffTokenVec<'content>)>,
}
impl<'content> UnifiedDiffHunk<'content> {
fn extend_context_lines(&mut self, lines: impl IntoIterator<Item = &'content [u8]>) {
let old_len = self.lines.len();
self.lines.extend(lines.into_iter().map(|line| {
let tokens = vec![(DiffTokenType::Matching, line)];
(DiffLineType::Context, tokens)
}));
self.left_line_range.end += self.lines.len() - old_len;
self.right_line_range.end += self.lines.len() - old_len;
}
fn extend_removed_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
let old_len = self.lines.len();
self.lines
.extend(lines.into_iter().map(|line| (DiffLineType::Removed, line)));
self.left_line_range.end += self.lines.len() - old_len;
}
fn extend_added_lines(&mut self, lines: impl IntoIterator<Item = DiffTokenVec<'content>>) {
let old_len = self.lines.len();
self.lines
.extend(lines.into_iter().map(|line| (DiffLineType::Added, line)));
self.right_line_range.end += self.lines.len() - old_len;
}
}
fn unified_diff_hunks<'content>(
left_content: &'content [u8],
right_content: &'content [u8],
options: &UnifiedDiffOptions,
) -> Vec<UnifiedDiffHunk<'content>> {
let mut hunks = vec![];
let mut current_hunk = UnifiedDiffHunk {
left_line_range: 1..1,
right_line_range: 1..1,
lines: vec![],
};
let diff = diff_by_line([left_content, right_content], &options.line_diff);
let mut diff_hunks = diff.hunks().peekable();
while let Some(hunk) = diff_hunks.next() {
match hunk.kind {
DiffHunkKind::Matching => {
let [_, right] = hunk.contents[..].try_into().unwrap();
let mut lines = right.split_inclusive(|b| *b == b'\n').fuse();
if !current_hunk.lines.is_empty() {
current_hunk.extend_context_lines(lines.by_ref().take(options.context));
}
let before_lines = if diff_hunks.peek().is_some() {
lines.by_ref().rev().take(options.context).collect()
} else {
vec![] };
let num_skip_lines = lines.count();
if num_skip_lines > 0 {
let left_start = current_hunk.left_line_range.end + num_skip_lines;
let right_start = current_hunk.right_line_range.end + num_skip_lines;
if !current_hunk.lines.is_empty() {
hunks.push(current_hunk);
}
current_hunk = UnifiedDiffHunk {
left_line_range: left_start..left_start,
right_line_range: right_start..right_start,
lines: vec![],
};
}
current_hunk.extend_context_lines(before_lines.into_iter().rev());
}
DiffHunkKind::Different => {
let (left_lines, right_lines) =
unzip_diff_hunks_to_lines(ContentDiff::by_word(hunk.contents).hunks());
current_hunk.extend_removed_lines(left_lines);
current_hunk.extend_added_lines(right_lines);
}
}
}
if !current_hunk.lines.is_empty() {
hunks.push(current_hunk);
}
hunks
}
#[allow(dead_code)]
fn unzip_diff_hunks_to_lines<'content, I>(
diff_hunks: I,
) -> (Vec<DiffTokenVec<'content>>, Vec<DiffTokenVec<'content>>)
where
I: IntoIterator,
I::Item: Borrow<DiffHunk<'content>>,
{
let mut left_lines: Vec<DiffTokenVec<'content>> = vec![];
let mut right_lines: Vec<DiffTokenVec<'content>> = vec![];
let mut left_tokens: DiffTokenVec<'content> = vec![];
let mut right_tokens: DiffTokenVec<'content> = vec![];
for hunk in diff_hunks {
let hunk = hunk.borrow();
match hunk.kind {
DiffHunkKind::Matching => {
debug_assert!(hunk.contents.iter().all_equal());
for token in hunk.contents[0].split_inclusive(|b| *b == b'\n') {
left_tokens.push((DiffTokenType::Matching, token));
right_tokens.push((DiffTokenType::Matching, token));
if token.ends_with(b"\n") {
left_lines.push(mem::take(&mut left_tokens));
right_lines.push(mem::take(&mut right_tokens));
}
}
}
DiffHunkKind::Different => {
let [left, right] = hunk.contents[..]
.try_into()
.expect("hunk should have exactly two inputs");
for token in left.split_inclusive(|b| *b == b'\n') {
left_tokens.push((DiffTokenType::Different, token));
if token.ends_with(b"\n") {
left_lines.push(mem::take(&mut left_tokens));
}
}
for token in right.split_inclusive(|b| *b == b'\n') {
right_tokens.push((DiffTokenType::Different, token));
if token.ends_with(b"\n") {
right_lines.push(mem::take(&mut right_tokens));
}
}
}
}
}
if !left_tokens.is_empty() {
left_lines.push(left_tokens);
}
if !right_tokens.is_empty() {
right_lines.push(right_tokens);
}
(left_lines, right_lines)
}
fn diff_by_line<'input, T: AsRef<[u8]> + ?Sized + 'input>(
inputs: impl IntoIterator<Item = &'input T>,
options: &LineDiffOptions,
) -> jj_lib::diff::ContentDiff<'input> {
use jj_lib::diff::ContentDiff;
match options.compare_mode {
LineCompareMode::Exact => {
ContentDiff::for_tokenizer(inputs, find_line_ranges, CompareBytesExactly)
}
LineCompareMode::IgnoreAllSpace => {
ContentDiff::for_tokenizer(inputs, find_line_ranges, CompareBytesIgnoreAllWhitespace)
}
LineCompareMode::IgnoreSpaceChange => {
ContentDiff::for_tokenizer(inputs, find_line_ranges, CompareBytesIgnoreWhitespaceAmount)
}
}
}
fn commit_id_identity(commit_id: &CommitId) -> &CommitId {
commit_id
}