2265 lines · 80.1 KB · Rust
RawDownload
use std::borrow::Cow;use std::path::Path;use std::sync::Arc; use futures::StreamExt as _;use futures::stream::BoxStream;use itertools::Itertools as _;use jj_lib::backend::BackendResult;use jj_lib::backend::CopyId;use jj_lib::backend::MergedTreeId;use jj_lib::backend::TreeValue;use jj_lib::conflicts;use jj_lib::conflicts::ConflictMarkerStyle;use jj_lib::conflicts::ConflictMaterializeOptions;use jj_lib::conflicts::MIN_CONFLICT_MARKER_LEN;use jj_lib::conflicts::MaterializedTreeValue;use jj_lib::conflicts::materialize_merge_result_to_bytes;use jj_lib::conflicts::materialized_diff_stream;use jj_lib::copies::CopiesTreeDiffEntry;use jj_lib::copies::CopyRecords;use jj_lib::diff::ContentDiff;use jj_lib::diff::DiffHunkKind;use jj_lib::files;use jj_lib::files::MergeResult;use jj_lib::matchers::Matcher;use jj_lib::merge::Merge;use jj_lib::merge::MergedTreeValue;use jj_lib::merged_tree::MergedTree;use jj_lib::merged_tree::MergedTreeBuilder;use jj_lib::object_id::ObjectId as _;use jj_lib::repo_path::RepoPath;use jj_lib::repo_path::RepoPathBuf;use jj_lib::store::Store;use jj_lib::tree_merge::MergeOptions;use pollster::FutureExt as _;use thiserror::Error; use super::MergeToolFile; #[derive(Debug, Error)]pub enum BuiltinToolError {    #[error("Failed to record changes")]    Record(#[from] scm_record::RecordError),    #[error("Failed to decode UTF-8 text for item {item} (this should not happen)")]    DecodeUtf8 {        source: std::str::Utf8Error,        item: &'static str,    },    #[error("Rendering {item} {id} is unimplemented for the builtin difftool/mergetool")]    Unimplemented { item: &'static str, id: String },    #[error("Backend error")]    BackendError(#[from] jj_lib::backend::BackendError),} #[derive(Clone, Debug)]enum FileContents {    Absent,    Text {        contents: String,        hash: Option<String>,        num_bytes: u64,    },    Binary {        hash: Option<String>,        num_bytes: u64,    },} impl FileContents {    fn describe(&self) -> Option<String> {        match self {            Self::Absent => None,            Self::Text {                contents: _,                hash,                num_bytes,            }            | Self::Binary { hash, num_bytes } => match hash {                Some(hash) => Some(format!("{hash} ({num_bytes}B)")),                None => Some(format!("({num_bytes}B)")),            },        }    }} /// Information about a file that was read from disk. Note that the file may not/// have existed, in which case its contents will be marked as absent.#[derive(Clone, Debug)]pub struct FileInfo {    file_mode: scm_record::FileMode,    contents: FileContents,} /// File modes according to the Git file mode conventions. used for display/// purposes and equality comparison.////// TODO: let `scm-record` accept strings instead of numbers for file modes? Or/// figure out some other way to represent file mode changes in a jj-compatible/// manner?mod mode {    pub const ABSENT: scm_record::FileMode = scm_record::FileMode::Absent;    pub const NORMAL: scm_record::FileMode = scm_record::FileMode::Unix(0o100644);    pub const EXECUTABLE: scm_record::FileMode = scm_record::FileMode::Unix(0o100755);    pub const SYMLINK: scm_record::FileMode = scm_record::FileMode::Unix(0o120000);} fn buf_to_file_contents(hash: Option<String>, buf: Vec<u8>) -> FileContents {    let num_bytes: u64 = buf.len().try_into().unwrap();    let text = if buf.contains(&0) {        None    } else {        String::from_utf8(buf).ok()    };    match text {        Some(text) => FileContents::Text {            contents: text,            hash,            num_bytes,        },        None => FileContents::Binary { hash, num_bytes },    }} fn read_file_contents(    materialized_value: MaterializedTreeValue,    path: &RepoPath,    materialize_options: &ConflictMaterializeOptions,) -> Result<FileInfo, BuiltinToolError> {    match materialized_value {        MaterializedTreeValue::Absent => Ok(FileInfo {            file_mode: mode::ABSENT,            contents: FileContents::Absent,        }),        MaterializedTreeValue::AccessDenied(err) => Ok(FileInfo {            file_mode: mode::NORMAL,            contents: FileContents::Text {                contents: format!("Access denied: {err}"),                hash: None,                num_bytes: 0,            },        }),         MaterializedTreeValue::File(mut file) => {            let buf = file.read_all(path).block_on()?;            let file_mode = if file.executable {                mode::EXECUTABLE            } else {                mode::NORMAL            };            let contents = buf_to_file_contents(Some(file.id.hex()), buf);            Ok(FileInfo {                file_mode,                contents,            })        }         MaterializedTreeValue::Symlink { id, target } => {            let file_mode = mode::SYMLINK;            let num_bytes = target.len().try_into().unwrap();            Ok(FileInfo {                file_mode,                contents: FileContents::Text {                    contents: target,                    hash: Some(id.hex()),                    num_bytes,                },            })        }         MaterializedTreeValue::Tree(tree_id) => {            unreachable!("list of changed files included a tree: {tree_id:?}");        }        MaterializedTreeValue::GitSubmodule(id) => Err(BuiltinToolError::Unimplemented {            item: "git submodule",            id: id.hex(),        }),        MaterializedTreeValue::FileConflict(file) => {            // Since scm_record doesn't support diffs of conflicts, file            // conflicts are compared in materialized form. The UI would look            // scary, but it can at least allow squashing resolved hunks.            let buf = materialize_merge_result_to_bytes(&file.contents, materialize_options).into();            // TODO: Render the ID somehow?            let contents = buf_to_file_contents(None, buf);            Ok(FileInfo {                file_mode: mode::NORMAL,                contents,            })        }        MaterializedTreeValue::OtherConflict { id } => {            // TODO: Non-file conflict shouldn't be rendered as a normal file            // TODO: Render the ID somehow?            let contents = buf_to_file_contents(None, id.describe().into_bytes());            Ok(FileInfo {                file_mode: mode::NORMAL,                contents,            })        }    }} fn make_section_changed_lines(    contents: &str,    change_type: scm_record::ChangeType,) -> Vec<scm_record::SectionChangedLine<'static>> {    contents        .split_inclusive('\n')        .map(|line| scm_record::SectionChangedLine {            is_checked: false,            change_type,            line: Cow::Owned(line.to_owned()),        })        .collect()} fn make_diff_sections(    left_contents: &str,    right_contents: &str,) -> Result<Vec<scm_record::Section<'static>>, BuiltinToolError> {    let diff = ContentDiff::by_line([left_contents.as_bytes(), right_contents.as_bytes()]);    let mut sections = Vec::new();    for hunk in diff.hunks() {        match hunk.kind {            DiffHunkKind::Matching => {                debug_assert!(hunk.contents.iter().all_equal());                let text = hunk.contents[0];                let text = str::from_utf8(text).map_err(|err| BuiltinToolError::DecodeUtf8 {                    source: err,                    item: "matching text in diff hunk",                })?;                sections.push(scm_record::Section::Unchanged {                    lines: text                        .split_inclusive('\n')                        .map(|line| Cow::Owned(line.to_owned()))                        .collect(),                });            }            DiffHunkKind::Different => {                let sides = &hunk.contents;                assert_eq!(sides.len(), 2, "only two inputs were provided to the diff");                let left_side =                    str::from_utf8(sides[0]).map_err(|err| BuiltinToolError::DecodeUtf8 {                        source: err,                        item: "left side of diff hunk",                    })?;                let right_side =                    str::from_utf8(sides[1]).map_err(|err| BuiltinToolError::DecodeUtf8 {                        source: err,                        item: "right side of diff hunk",                    })?;                sections.push(scm_record::Section::Changed {                    lines: [                        make_section_changed_lines(left_side, scm_record::ChangeType::Removed),                        make_section_changed_lines(right_side, scm_record::ChangeType::Added),                    ]                    .concat(),                });            }        }    }    Ok(sections)} async fn make_diff_files(    store: &Arc<Store>,    tree_diff: BoxStream<'_, CopiesTreeDiffEntry>,    marker_style: ConflictMarkerStyle,) -> Result<(Vec<RepoPathBuf>, Vec<scm_record::File<'static>>), BuiltinToolError> {    let materialize_options = ConflictMaterializeOptions {        marker_style,        marker_len: None,        merge: store.merge_options().clone(),    };    let mut diff_stream = materialized_diff_stream(store, tree_diff);    let mut changed_files = Vec::new();    let mut files = Vec::new();    while let Some(entry) = diff_stream.next().await {        let left_path = entry.path.source();        let right_path = entry.path.target();        let (left_value, right_value) = entry.values?;        let left_info = read_file_contents(left_value, left_path, &materialize_options)?;        let right_info = read_file_contents(right_value, right_path, &materialize_options)?;        let mut sections = Vec::new();         if left_info.file_mode != right_info.file_mode {            sections.push(scm_record::Section::FileMode {                is_checked: false,                mode: right_info.file_mode,            });        }         match (left_info.contents, right_info.contents) {            (FileContents::Absent, FileContents::Absent) => {}            // In this context, `Absent` means the file doesn't exist. If it only            // exists on one side, we will render a mode change section above.            // The next two patterns are to avoid also rendering an empty            // changed lines section that clutters the UI.            (                FileContents::Absent,                FileContents::Text {                    contents: _,                    hash: _,                    num_bytes: 0,                },            ) => {}            (                FileContents::Text {                    contents: _,                    hash: _,                    num_bytes: 0,                },                FileContents::Absent,            ) => {}            (                FileContents::Absent,                FileContents::Text {                    contents,                    hash: _,                    num_bytes: _,                },            ) => sections.push(scm_record::Section::Changed {                lines: make_section_changed_lines(&contents, scm_record::ChangeType::Added),            }),             (                FileContents::Text {                    contents,                    hash: _,                    num_bytes: _,                },                FileContents::Absent,            ) => sections.push(scm_record::Section::Changed {                lines: make_section_changed_lines(&contents, scm_record::ChangeType::Removed),            }),             (                FileContents::Text {                    contents: old_contents,                    hash: _,                    num_bytes: _,                },                FileContents::Text {                    contents: new_contents,                    hash: _,                    num_bytes: _,                },            ) => {                sections.extend(make_diff_sections(&old_contents, &new_contents)?);            }             (                FileContents::Binary {                    hash: Some(left_hash),                    ..                },                FileContents::Binary {                    hash: Some(right_hash),                    ..                },            ) if left_hash == right_hash => {                // Binary file contents have not changed.            }            (left, right @ FileContents::Binary { .. })            | (left @ FileContents::Binary { .. }, right) => {                sections.push(scm_record::Section::Binary {                    is_checked: false,                    old_description: left.describe().map(Cow::Owned),                    new_description: right.describe().map(Cow::Owned),                });            }        }         files.push(scm_record::File {            old_path: None,            // Path for displaying purposes, not for file access.            path: Cow::Owned(right_path.to_fs_path_unchecked(Path::new(""))),            file_mode: left_info.file_mode,            sections,        });        changed_files.push(entry.path.target);    }    Ok((changed_files, files))} fn apply_diff_builtin(    store: &Arc<Store>,    left_tree: &MergedTree,    right_tree: &MergedTree,    changed_files: Vec<RepoPathBuf>,    files: &[scm_record::File],) -> BackendResult<MergedTreeId> {    // Start with the right tree to match external tool behavior.    // This ensures unmatched paths keep their values from the right tree.    let mut tree_builder = MergedTreeBuilder::new(right_tree.id().clone());     // First, revert all changed files to their left versions    for path in &changed_files {        let left_value = left_tree.path_value(path)?;        tree_builder.set_or_remove(path.clone(), left_value);    }     // Then apply only the selected changes    apply_changes(        &mut tree_builder,        changed_files,        files,        |path| left_tree.path_value(path),        |path| right_tree.path_value(path),        |path, contents, executable, copy_id| {            let old_value = left_tree.path_value(path)?;            let new_value = if old_value.is_resolved() {                let id = store.write_file(path, &mut &contents[..]).block_on()?;                Merge::normal(TreeValue::File {                    id,                    executable,                    copy_id,                })            } else if let Some(old_file_ids) = old_value.to_file_merge() {                // TODO: should error out if conflicts couldn't be parsed?                let new_file_ids = conflicts::update_from_content(                    &old_file_ids,                    store,                    path,                    contents,                    MIN_CONFLICT_MARKER_LEN, // TODO: use the materialization parameter                )                .block_on()?;                match new_file_ids.into_resolved() {                    Ok(id) => Merge::resolved(id.map(|id| TreeValue::File {                        id,                        executable,                        copy_id: CopyId::placeholder(),                    })),                    Err(file_ids) => old_value.with_new_file_ids(&file_ids),                }            } else {                panic!("unexpected content change at {path:?}: {old_value:?}");            };            Ok(new_value)        },    )?;    tree_builder.write_tree(store)} fn apply_changes(    tree_builder: &mut MergedTreeBuilder,    changed_files: Vec<RepoPathBuf>,    files: &[scm_record::File],    select_left: impl Fn(&RepoPath) -> BackendResult<MergedTreeValue>,    select_right: impl Fn(&RepoPath) -> BackendResult<MergedTreeValue>,    write_file: impl Fn(&RepoPath, &[u8], bool, CopyId) -> BackendResult<MergedTreeValue>,) -> BackendResult<()> {    assert_eq!(        changed_files.len(),        files.len(),        "result had a different number of files"    );    // TODO: Write files concurrently    for (path, file) in changed_files.into_iter().zip(files) {        let file_mode_change_selected = file            .sections            .iter()            .find_map(|sec| match sec {                scm_record::Section::FileMode { is_checked, .. } => Some(*is_checked),                _ => None,            })            .unwrap_or(false);         let (            scm_record::SelectedChanges {                contents,                file_mode,            },            _unselected,        ) = file.get_selected_contents();         if file_mode == mode::ABSENT {            // The file is not present in the selected changes.            // Either a file mode change was selected to delete an existing file, so we            // should remove it from the tree,            if file_mode_change_selected {                tree_builder.set_or_remove(path, Merge::absent());            }            // or the file's creation has been split out of the change, in which case we            // don't need to change the tree.            // In either case, we're done with this file afterwards.            continue;        }         let executable = file_mode == mode::EXECUTABLE;        match contents {            scm_record::SelectedContents::Unchanged => {                if file_mode_change_selected {                    // File contents haven't changed, but file mode needs to be updated on the tree.                    let value = override_file_executable_bit(select_left(&path)?, executable);                    tree_builder.set_or_remove(path, value);                } else {                    // Neither file mode, nor contents changed => Do nothing.                }            }            scm_record::SelectedContents::Binary {

Lines 1–500 of 2265.