use std::borrow::Cow;use std::path::Path;use std::sync::Arc; use futures::StreamExt as _;use futures::TryFutureExt as _;use futures::TryStreamExt as _;use itertools::Itertools as _;use jj_lib::backend::BackendResult;use jj_lib::backend::MergedTreeId;use jj_lib::backend::TreeValue;use jj_lib::conflicts::materialize_merge_result_to_bytes;use jj_lib::conflicts::materialize_tree_value;use jj_lib::conflicts::ConflictMarkerStyle;use jj_lib::conflicts::MaterializedTreeValue;use jj_lib::diff::Diff;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::merged_tree::MergedTree;use jj_lib::merged_tree::MergedTreeBuilder;use jj_lib::merged_tree::TreeDiffEntry;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 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, },} /// 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 describe_binary(hash: Option<&str>, num_bytes: u64) -> String { match hash { Some(hash) => { format!("{hash} ({num_bytes}B)") } None => format!("({num_bytes}B)"), }} 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( store: &Store, tree: &MergedTree, path: &RepoPath, conflict_marker_style: ConflictMarkerStyle,) -> Result<FileInfo, BuiltinToolError> { let value = tree.path_value(path)?; let materialized_value = materialize_tree_value(store, path, value) .map_err(BuiltinToolError::BackendError) .block_on()?; 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)?; 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 { id: _, contents, executable: _, } => { let buf = materialize_merge_result_to_bytes(&contents, conflict_marker_style).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: 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 = Diff::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 = std::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 = std::str::from_utf8(sides[0]).map_err(|err| BuiltinToolError::DecodeUtf8 { source: err, item: "left side of diff hunk", })?; let right_side = std::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)} pub fn make_diff_files( store: &Arc<Store>, left_tree: &MergedTree, right_tree: &MergedTree, changed_files: &[RepoPathBuf], conflict_marker_style: ConflictMarkerStyle,) -> Result<Vec<scm_record::File<'static>>, BuiltinToolError> { let mut files = Vec::new(); for changed_path in changed_files { let left_info = read_file_contents(store, left_tree, changed_path, conflict_marker_style)?; let right_info = read_file_contents(store, right_tree, changed_path, conflict_marker_style)?; 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::Absent, FileContents::Binary { hash, num_bytes }) => { sections.push(scm_record::Section::Binary { is_checked: false, old_description: None, new_description: Some(Cow::Owned(describe_binary(hash.as_deref(), num_bytes))), }); } ( 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::Text { contents: _, hash: old_hash, num_bytes: old_num_bytes, } | FileContents::Binary { hash: old_hash, num_bytes: old_num_bytes, }, FileContents::Text { contents: _, hash: new_hash, num_bytes: new_num_bytes, } | FileContents::Binary { hash: new_hash, num_bytes: new_num_bytes, }, ) => sections.push(scm_record::Section::Binary { is_checked: false, old_description: Some(Cow::Owned(describe_binary( old_hash.as_deref(), old_num_bytes, ))), new_description: Some(Cow::Owned(describe_binary( new_hash.as_deref(), new_num_bytes, ))), }), (FileContents::Binary { hash, num_bytes }, FileContents::Absent) => { sections.push(scm_record::Section::Binary { is_checked: false, old_description: Some(Cow::Owned(describe_binary(hash.as_deref(), num_bytes))), new_description: None, }); } } files.push(scm_record::File { old_path: None, // Path for displaying purposes, not for file access. path: Cow::Owned(changed_path.to_fs_path_unchecked(Path::new(""))), file_mode: left_info.file_mode, sections, }); } Ok(files)} pub fn apply_diff_builtin( store: &Arc<Store>, left_tree: &MergedTree, right_tree: &MergedTree, changed_files: Vec<RepoPathBuf>, files: &[scm_record::File],) -> BackendResult<MergedTreeId> { let mut tree_builder = MergedTreeBuilder::new(left_tree.id().clone()); 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 ( scm_record::SelectedChanges { contents, file_mode, }, _unselected, ) = file.get_selected_contents(); // If a file was not present in the selected changes (i.e. split out of a // change, deleted, etc.) remove it from the tree. if file_mode == scm_record::FileMode::Absent { tree_builder.set_or_remove(path, Merge::absent()); continue; } match contents { scm_record::SelectedContents::Unchanged => { // Do nothing. } scm_record::SelectedContents::Binary { old_description: _, new_description: _, } => { let value = right_tree.path_value(&path)?; tree_builder.set_or_remove(path, value); } scm_record::SelectedContents::Text { contents } => { let file_id = store .write_file(&path, &mut contents.as_bytes()) .block_on()?; tree_builder.set_or_remove( path, Merge::normal(TreeValue::File { id: file_id, executable: file_mode == mode::EXECUTABLE, }), ); } } } let tree_id = tree_builder.write_tree(left_tree.store())?; Ok(tree_id)} pub fn edit_diff_builtin( left_tree: &MergedTree, right_tree: &MergedTree, matcher: &dyn Matcher, conflict_marker_style: ConflictMarkerStyle,) -> Result<MergedTreeId, BuiltinToolError> { let store = left_tree.store().clone(); // TODO: handle copy tracking let changed_files: Vec<_> = left_tree .diff_stream(right_tree, matcher) .map(|TreeDiffEntry { path, values }| values.map(|_| path)) .try_collect() .block_on()?; let files = make_diff_files( &store, left_tree, right_tree, &changed_files, conflict_marker_style, )?; let mut input = scm_record::helpers::CrosstermInput; let recorder = scm_record::Recorder::new( scm_record::RecordState { is_read_only: false, files, commits: Default::default(), }, &mut input, ); let result = recorder.run().map_err(BuiltinToolError::Record)?; let tree_id = apply_diff_builtin(&store, left_tree, right_tree, changed_files, &result.files) .map_err(BuiltinToolError::BackendError)?; Ok(tree_id)} fn make_merge_sections( merge_result: MergeResult,) -> Result<Vec<scm_record::Section<'static>>, BuiltinToolError> { let mut sections = Vec::new(); match merge_result { MergeResult::Resolved(buf) => { let contents = buf_to_file_contents(None, buf.into()); let section = match contents { FileContents::Absent => None, FileContents::Text { contents,Lines 1–500 of 1293. Load more
use std::borrow::Cow; use std::path::Path; use std::sync::Arc; use futures::StreamExt as _; use futures::TryFutureExt as _; use futures::TryStreamExt as _; use itertools::Itertools as _; use jj_lib::backend::BackendResult; use jj_lib::backend::MergedTreeId; use jj_lib::backend::TreeValue; use jj_lib::conflicts::materialize_merge_result_to_bytes; use jj_lib::conflicts::materialize_tree_value; use jj_lib::conflicts::ConflictMarkerStyle; use jj_lib::conflicts::MaterializedTreeValue; use jj_lib::diff::Diff; 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::merged_tree::MergedTree; use jj_lib::merged_tree::MergedTreeBuilder; use jj_lib::merged_tree::TreeDiffEntry; 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 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, }, } /// 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 describe_binary(hash: Option<&str>, num_bytes: u64) -> String { match hash { Some(hash) => { format!("{hash} ({num_bytes}B)") } None => format!("({num_bytes}B)"), } } 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( store: &Store, tree: &MergedTree, path: &RepoPath, conflict_marker_style: ConflictMarkerStyle, ) -> Result<FileInfo, BuiltinToolError> { let value = tree.path_value(path)?; let materialized_value = materialize_tree_value(store, path, value) .map_err(BuiltinToolError::BackendError) .block_on()?; 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)?; 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 { id: _, contents, executable: _, } => { let buf = materialize_merge_result_to_bytes(&contents, conflict_marker_style).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: 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 = Diff::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 = std::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 = std::str::from_utf8(sides[0]).map_err(|err| BuiltinToolError::DecodeUtf8 { source: err, item: "left side of diff hunk", })?; let right_side = std::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) } pub fn make_diff_files( store: &Arc<Store>, left_tree: &MergedTree, right_tree: &MergedTree, changed_files: &[RepoPathBuf], conflict_marker_style: ConflictMarkerStyle, ) -> Result<Vec<scm_record::File<'static>>, BuiltinToolError> { let mut files = Vec::new(); for changed_path in changed_files { let left_info = read_file_contents(store, left_tree, changed_path, conflict_marker_style)?; let right_info = read_file_contents(store, right_tree, changed_path, conflict_marker_style)?; 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::Absent, FileContents::Binary { hash, num_bytes }) => { sections.push(scm_record::Section::Binary { is_checked: false, old_description: None, new_description: Some(Cow::Owned(describe_binary(hash.as_deref(), num_bytes))), }); } ( 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::Text { contents: _, hash: old_hash, num_bytes: old_num_bytes, } | FileContents::Binary { hash: old_hash, num_bytes: old_num_bytes, }, FileContents::Text { contents: _, hash: new_hash, num_bytes: new_num_bytes, } | FileContents::Binary { hash: new_hash, num_bytes: new_num_bytes, }, ) => sections.push(scm_record::Section::Binary { is_checked: false, old_description: Some(Cow::Owned(describe_binary( old_hash.as_deref(), old_num_bytes, ))), new_description: Some(Cow::Owned(describe_binary( new_hash.as_deref(), new_num_bytes, ))), }), (FileContents::Binary { hash, num_bytes }, FileContents::Absent) => { sections.push(scm_record::Section::Binary { is_checked: false, old_description: Some(Cow::Owned(describe_binary(hash.as_deref(), num_bytes))), new_description: None, }); } } files.push(scm_record::File { old_path: None, // Path for displaying purposes, not for file access. path: Cow::Owned(changed_path.to_fs_path_unchecked(Path::new(""))), file_mode: left_info.file_mode, sections, }); } Ok(files) } pub fn apply_diff_builtin( store: &Arc<Store>, left_tree: &MergedTree, right_tree: &MergedTree, changed_files: Vec<RepoPathBuf>, files: &[scm_record::File], ) -> BackendResult<MergedTreeId> { let mut tree_builder = MergedTreeBuilder::new(left_tree.id().clone()); 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 ( scm_record::SelectedChanges { contents, file_mode, }, _unselected, ) = file.get_selected_contents(); // If a file was not present in the selected changes (i.e. split out of a // change, deleted, etc.) remove it from the tree. if file_mode == scm_record::FileMode::Absent { tree_builder.set_or_remove(path, Merge::absent()); continue; } match contents { scm_record::SelectedContents::Unchanged => { // Do nothing. } scm_record::SelectedContents::Binary { old_description: _, new_description: _, } => { let value = right_tree.path_value(&path)?; tree_builder.set_or_remove(path, value); } scm_record::SelectedContents::Text { contents } => { let file_id = store .write_file(&path, &mut contents.as_bytes()) .block_on()?; tree_builder.set_or_remove( path, Merge::normal(TreeValue::File { id: file_id, executable: file_mode == mode::EXECUTABLE, }), ); } } } let tree_id = tree_builder.write_tree(left_tree.store())?; Ok(tree_id) } pub fn edit_diff_builtin( left_tree: &MergedTree, right_tree: &MergedTree, matcher: &dyn Matcher, conflict_marker_style: ConflictMarkerStyle, ) -> Result<MergedTreeId, BuiltinToolError> { let store = left_tree.store().clone(); // TODO: handle copy tracking let changed_files: Vec<_> = left_tree .diff_stream(right_tree, matcher) .map(|TreeDiffEntry { path, values }| values.map(|_| path)) .try_collect() .block_on()?; let files = make_diff_files( &store, left_tree, right_tree, &changed_files, conflict_marker_style, )?; let mut input = scm_record::helpers::CrosstermInput; let recorder = scm_record::Recorder::new( scm_record::RecordState { is_read_only: false, files, commits: Default::default(), }, &mut input, ); let result = recorder.run().map_err(BuiltinToolError::Record)?; let tree_id = apply_diff_builtin(&store, left_tree, right_tree, changed_files, &result.files) .map_err(BuiltinToolError::BackendError)?; Ok(tree_id) } fn make_merge_sections( merge_result: MergeResult, ) -> Result<Vec<scm_record::Section<'static>>, BuiltinToolError> { let mut sections = Vec::new(); match merge_result { MergeResult::Resolved(buf) => { let contents = buf_to_file_contents(None, buf.into()); let section = match contents { FileContents::Absent => None, FileContents::Text { contents,