Skip to content

Commit

Permalink
resolve: try to resolve all conflicted files in fileset
Browse files Browse the repository at this point in the history
If many files are conflicted, it would be nice to be able to resolve all
conflicts at once without having to run `jj resolve` multiple times.
This is especially nice for merge tools which try to automatically
resolve conflicts without user input, but it is also good for regular
merge editors like VS Code.

This change makes the behavior of `jj resolve` more consistent with
other commands which accept filesets since it will use the entire
fileset instead of picking an arbitrary file from the fileset.

Since we don't support passing directories to merge tools yet, the
current implementation just calls the merge tool repeatedly in a loop
until every file is resolved, or until an error occurs. If an error
occurs after successfully resolving at least one file, it is downgraded
to a warning instead. This means the user can just close the editor at
any point to cancel resolution on all remaining files.
  • Loading branch information
scott2000 committed Dec 17, 2024
1 parent 86a26bb commit 69c8240
Show file tree
Hide file tree
Showing 8 changed files with 327 additions and 95 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* The deprecated `[alias]` config section is no longer respected. Move command
aliases to the `[aliases]` section.

* `jj resolve` will now attempt to resolve all conflicted files instead of
resolving the first conflicted file. To resolve a single file, pass a file
path to `jj resolve`.

### Deprecations

* `--config-toml=TOML` is deprecated in favor of `--config=NAME=VALUE` and
Expand Down
8 changes: 6 additions & 2 deletions cli/src/command_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,11 @@ impl From<DiffRenderError> for CommandError {

impl From<ConflictResolveError> for CommandError {
fn from(err: ConflictResolveError) -> Self {
user_error_with_message("Failed to resolve conflicts", err)
match err {
ConflictResolveError::Backend(err) => err.into(),
ConflictResolveError::Io(err) => err.into(),
_ => user_error_with_message("Failed to resolve conflicts", err),
}
}
}

Expand Down Expand Up @@ -785,7 +789,7 @@ fn print_error(
Ok(())
}

fn print_error_sources(ui: &Ui, source: Option<&dyn error::Error>) -> io::Result<()> {
pub fn print_error_sources(ui: &Ui, source: Option<&dyn error::Error>) -> io::Result<()> {
let Some(err) = source else {
return Ok(());
};
Expand Down
32 changes: 18 additions & 14 deletions cli/src/commands/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ use crate::command_error::CommandError;
use crate::complete;
use crate::ui::Ui;

/// Resolve a conflicted file with an external merge tool
/// Resolve conflicted files with an external merge tool
///
/// Only conflicts that can be resolved with a 3-way merge are supported. See
/// docs for merge tool configuration instructions.
/// docs for merge tool configuration instructions. External merge tools will be
/// invoked for each conflicted file one-by-one until all conflicts are
/// resolved. To stop resolving conflicts, exit the merge tool without making
/// any changes.
///
/// Note that conflicts can also be resolved without using this command. You may
/// edit the conflict markers in the conflicted file directly with a text
Expand All @@ -51,18 +54,16 @@ pub(crate) struct ResolveArgs {
add = ArgValueCandidates::new(complete::mutable_revisions),
)]
revision: RevisionArg,
/// Instead of resolving one conflict, list all the conflicts
/// Instead of resolving conflicts, list all the conflicts
// TODO: Also have a `--summary` option. `--list` currently acts like
// `diff --summary`, but should be more verbose.
#[arg(long, short)]
list: bool,
/// Specify 3-way merge tool to be used
#[arg(long, conflicts_with = "list", value_name = "NAME")]
tool: Option<String>,
/// Restrict to these paths when searching for a conflict to resolve. We
/// will attempt to resolve the first conflict we can find. You can use
/// the `--list` argument to find paths to use here.
// TODO: Find the conflict we can resolve even if it's not the first one.
/// Only resolve conflicts in these paths. You can use the `--list` argument
/// to find paths to use here.
#[arg(
value_hint = clap::ValueHint::AnyPath,
add = ArgValueCompleter::new(complete::revision_conflicted_files),
Expand Down Expand Up @@ -101,16 +102,19 @@ pub(crate) fn cmd_resolve(
);
};

let (repo_path, _) = conflicts.first().unwrap();
let repo_paths = conflicts
.iter()
.map(|(path, _)| path.as_ref())
.collect_vec();
workspace_command.check_rewritable([commit.id()])?;
let merge_editor = workspace_command.merge_editor(ui, args.tool.as_deref())?;
writeln!(
ui.status(),
"Resolving conflicts in: {}",
workspace_command.format_file_path(repo_path)
)?;
let mut tx = workspace_command.start_transaction();
let new_tree_id = merge_editor.edit_file(&tree, repo_path)?;
let new_tree_id = merge_editor.edit_file(
ui,
tx.base_workspace_helper().path_converter(),
&tree,
&repo_paths,
)?;
let new_commit = tx
.repo_mut()
.rewrite_commit(command.settings(), &commit)
Expand Down
12 changes: 7 additions & 5 deletions cli/src/merge_tools/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,26 +658,28 @@ fn make_merge_file(

pub fn edit_merge_builtin(
tree: &MergedTree,
merge_tool_file: &MergeToolFile,
merge_tool_files: &[MergeToolFile],
) -> Result<MergedTreeId, BuiltinToolError> {
let mut input = scm_record::helpers::CrosstermInput;
let recorder = scm_record::Recorder::new(
scm_record::RecordState {
is_read_only: false,
files: vec![make_merge_file(merge_tool_file)?],
files: merge_tool_files.iter().map(make_merge_file).try_collect()?,
commits: Default::default(),
},
&mut input,
);
let state = recorder.run()?;

let file = state.files.into_iter().exactly_one().unwrap();
apply_diff_builtin(
tree.store(),
tree,
tree,
vec![merge_tool_file.repo_path.clone()],
&[file],
merge_tool_files
.iter()
.map(|file| file.repo_path.clone())
.collect_vec(),
&state.files,
)
.map_err(BuiltinToolError::BackendError)
}
Expand Down
58 changes: 51 additions & 7 deletions cli/src/merge_tools/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ 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::repo_path::RepoPathUiConverter;
use jj_lib::store::Store;
use jj_lib::working_copy::CheckoutOptions;
use pollster::FutureExt;
use thiserror::Error;
Expand All @@ -31,6 +33,7 @@ use super::ConflictResolveError;
use super::DiffEditError;
use super::DiffGenerateError;
use super::MergeToolFile;
use crate::command_error::print_error_sources;
use crate::config::find_all_variables;
use crate::config::interpolate_variables;
use crate::config::CommandNameAndArgs;
Expand Down Expand Up @@ -166,12 +169,13 @@ pub enum ExternalToolError {
Io(#[source] std::io::Error),
}

pub fn run_mergetool_external(
fn run_mergetool_external_single_file(
editor: &ExternalMergeTool,
tree: &MergedTree,
store: &Store,
merge_tool_file: &MergeToolFile,
default_conflict_marker_style: ConflictMarkerStyle,
) -> Result<MergedTreeId, ConflictResolveError> {
tree_builder: &mut MergedTreeBuilder,
) -> Result<(), ConflictResolveError> {
let MergeToolFile {
repo_path,
conflict,
Expand Down Expand Up @@ -255,15 +259,14 @@ pub fn run_mergetool_external(
let new_file_ids = if editor.merge_tool_edits_conflict_markers || exit_status_implies_conflict {
conflicts::update_from_content(
file_merge,
tree.store(),
store,
repo_path,
output_file_contents.as_slice(),
conflict_marker_style,
)
.block_on()?
} else {
let new_file_id = tree
.store()
let new_file_id = store
.write_file(repo_path, &mut output_file_contents.as_slice())
.block_on()?;
Merge::normal(new_file_id)
Expand All @@ -286,8 +289,49 @@ pub fn run_mergetool_external(
}),
Err(new_file_ids) => conflict.with_new_file_ids(&new_file_ids),
};
let mut tree_builder = MergedTreeBuilder::new(tree.id());
tree_builder.set_or_remove(repo_path.to_owned(), new_tree_value);
Ok(())
}

pub fn run_mergetool_external(
ui: &Ui,
path_converter: &RepoPathUiConverter,
editor: &ExternalMergeTool,
tree: &MergedTree,
merge_tool_files: &[MergeToolFile],
default_conflict_marker_style: ConflictMarkerStyle,
) -> Result<MergedTreeId, ConflictResolveError> {
let mut tree_builder = MergedTreeBuilder::new(tree.id());
for (i, merge_tool_file) in merge_tool_files.iter().enumerate() {
writeln!(
ui.status(),
"Resolving conflicts in: {}",
path_converter.format_file_path(&merge_tool_file.repo_path)
)?;
match run_mergetool_external_single_file(
editor,
tree.store(),
merge_tool_file,
default_conflict_marker_style,
&mut tree_builder,
) {
Ok(()) => {}
Err(err) if i == 0 => {
// Since no conflicts were successfully resolved, return the error
return Err(err);
}
Err(err) => {
// Since some conflicts were already successfully resolved, just print a warning
// and stop resolving conflicts
writeln!(
ui.warning_default(),
"Stopping due to error after resolving {i} conflicts"
)?;
print_error_sources(ui, Some(&err))?;
break;
}
}
}
let new_tree = tree_builder.write_tree(tree.store())?;
Ok(new_tree)
}
Expand Down
76 changes: 47 additions & 29 deletions cli/src/merge_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod external;
use std::sync::Arc;

use bstr::BString;
use itertools::Itertools;
use jj_lib::backend::FileId;
use jj_lib::backend::MergedTreeId;
use jj_lib::config::ConfigGetError;
Expand All @@ -34,6 +35,7 @@ use jj_lib::merged_tree::MergedTree;
use jj_lib::repo_path::InvalidRepoPathError;
use jj_lib::repo_path::RepoPath;
use jj_lib::repo_path::RepoPathBuf;
use jj_lib::repo_path::RepoPathUiConverter;
use jj_lib::settings::UserSettings;
use jj_lib::working_copy::SnapshotError;
use pollster::FutureExt;
Expand Down Expand Up @@ -101,8 +103,10 @@ pub enum ConflictResolveError {
see the exact invocation)."
)]
EmptyOrUnchanged,
#[error("Backend error")]
#[error(transparent)]
Backend(#[from] jj_lib::backend::BackendError),
#[error(transparent)]
Io(#[from] std::io::Error),
}

#[derive(Debug, Error)]
Expand Down Expand Up @@ -324,44 +328,58 @@ impl MergeEditor {
/// Starts a merge editor for the specified file.
pub fn edit_file(
&self,
ui: &Ui,
path_converter: &RepoPathUiConverter,
tree: &MergedTree,
repo_path: &RepoPath,
repo_paths: &[&RepoPath],
) -> Result<MergedTreeId, ConflictResolveError> {
let conflict = match tree.path_value(repo_path)?.into_resolved() {
Err(conflict) => conflict,
Ok(Some(_)) => return Err(ConflictResolveError::NotAConflict(repo_path.to_owned())),
Ok(None) => return Err(ConflictResolveError::PathNotFound(repo_path.to_owned())),
};
let file_merge = conflict.to_file_merge().ok_or_else(|| {
let summary = conflict.describe();
ConflictResolveError::NotNormalFiles(repo_path.to_owned(), summary)
})?;
let simplified_file_merge = file_merge.clone().simplify();
// We only support conflicts with 2 sides (3-way conflicts)
if simplified_file_merge.num_sides() > 2 {
return Err(ConflictResolveError::ConflictTooComplicated {
path: repo_path.to_owned(),
sides: simplified_file_merge.num_sides(),
});
};
let content =
extract_as_single_hunk(&simplified_file_merge, tree.store(), repo_path).block_on()?;
let merge_tool_file = MergeToolFile {
repo_path: repo_path.to_owned(),
conflict,
file_merge,
content,
};
let merge_tool_files: Vec<MergeToolFile> = repo_paths
.iter()
.map(|&repo_path| {
let conflict = match tree.path_value(repo_path)?.into_resolved() {
Err(conflict) => conflict,
Ok(Some(_)) => {
return Err(ConflictResolveError::NotAConflict(repo_path.to_owned()))
}
Ok(None) => {
return Err(ConflictResolveError::PathNotFound(repo_path.to_owned()))
}
};
let file_merge = conflict.to_file_merge().ok_or_else(|| {
let summary = conflict.describe();
ConflictResolveError::NotNormalFiles(repo_path.to_owned(), summary)
})?;
let simplified_file_merge = file_merge.clone().simplify();
// We only support conflicts with 2 sides (3-way conflicts)
if simplified_file_merge.num_sides() > 2 {
return Err(ConflictResolveError::ConflictTooComplicated {
path: repo_path.to_owned(),
sides: simplified_file_merge.num_sides(),
});
};
let content =
extract_as_single_hunk(&simplified_file_merge, tree.store(), repo_path)
.block_on()?;
Ok(MergeToolFile {
repo_path: repo_path.to_owned(),
conflict,
file_merge,
content,
})
})
.try_collect()?;

match &self.tool {
MergeTool::Builtin => {
let tree_id = edit_merge_builtin(tree, &merge_tool_file).map_err(Box::new)?;
let tree_id = edit_merge_builtin(tree, &merge_tool_files).map_err(Box::new)?;
Ok(tree_id)
}
MergeTool::External(editor) => external::run_mergetool_external(
ui,
path_converter,
editor,
tree,
&merge_tool_file,
&merge_tool_files,
self.conflict_marker_style,
),
}
Expand Down
10 changes: 5 additions & 5 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ To get started, see the tutorial at https://martinvonz.github.io/jj/latest/tutor
* `parallelize`Parallelize revisions by making them siblings
* `prev`Change the working copy revision relative to the parent revision
* `rebase`Move revisions to different parent(s)
* `resolve`Resolve a conflicted file with an external merge tool
* `resolve`Resolve conflicted files with an external merge tool
* `restore`Restore paths from another revision
* `root`Show the current workspace root directory
* `show`Show commit description and changes in a revision
Expand Down Expand Up @@ -1884,24 +1884,24 @@ commit. This is true in general; it is not specific to this command.
## `jj resolve`
Resolve a conflicted file with an external merge tool
Resolve conflicted files with an external merge tool
Only conflicts that can be resolved with a 3-way merge are supported. See docs for merge tool configuration instructions.
Only conflicts that can be resolved with a 3-way merge are supported. See docs for merge tool configuration instructions. External merge tools will be invoked for each conflicted file one-by-one until all conflicts are resolved. To stop resolving conflicts, exit the merge tool without making any changes.
Note that conflicts can also be resolved without using this command. You may edit the conflict markers in the conflicted file directly with a text editor.
**Usage:** `jj resolve [OPTIONS] [PATHS]...`
###### **Arguments:**
* `<PATHS>` — Restrict to these paths when searching for a conflict to resolve. We will attempt to resolve the first conflict we can find. You can use the `--list` argument to find paths to use here
* `<PATHS>` — Only resolve conflicts in these paths. You can use the `--list` argument to find paths to use here
###### **Options:**
* `-r`, `--revision <REVISION>`
Default value: `@`
* `-l`, `--list` — Instead of resolving one conflict, list all the conflicts
* `-l`, `--list` — Instead of resolving conflicts, list all the conflicts
* `--tool <NAME>` — Specify 3-way merge tool to be used
Expand Down
Loading

0 comments on commit 69c8240

Please sign in to comment.