mirror of
https://github.com/martinvonz/jj.git
synced 2025-05-05 15:32:49 +00:00
612 lines
22 KiB
Rust
612 lines
22 KiB
Rust
// Copyright 2024 The Jujutsu Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use std::collections::HashMap;
|
|
use std::convert::Infallible;
|
|
use std::sync::Arc;
|
|
|
|
use clap_complete::ArgValueCandidates;
|
|
use indexmap::IndexMap;
|
|
use itertools::Itertools as _;
|
|
use jj_lib::backend::ChangeId;
|
|
use jj_lib::backend::CommitId;
|
|
use jj_lib::commit::Commit;
|
|
use jj_lib::dag_walk;
|
|
use jj_lib::graph::GraphEdge;
|
|
use jj_lib::graph::TopoGroupedGraphIterator;
|
|
use jj_lib::matchers::EverythingMatcher;
|
|
use jj_lib::op_store::RefTarget;
|
|
use jj_lib::op_store::RemoteRef;
|
|
use jj_lib::op_store::RemoteRefState;
|
|
use jj_lib::refs::diff_named_commit_ids;
|
|
use jj_lib::refs::diff_named_ref_targets;
|
|
use jj_lib::refs::diff_named_remote_refs;
|
|
use jj_lib::repo::ReadonlyRepo;
|
|
use jj_lib::repo::Repo;
|
|
use jj_lib::revset;
|
|
use jj_lib::revset::RevsetIteratorExt as _;
|
|
|
|
use crate::cli_util::CommandHelper;
|
|
use crate::cli_util::LogContentFormat;
|
|
use crate::command_error::CommandError;
|
|
use crate::commit_templater::CommitTemplateLanguage;
|
|
use crate::complete;
|
|
use crate::diff_util::diff_formats_for_log;
|
|
use crate::diff_util::DiffFormatArgs;
|
|
use crate::diff_util::DiffRenderer;
|
|
use crate::formatter::Formatter;
|
|
use crate::graphlog::get_graphlog;
|
|
use crate::graphlog::GraphStyle;
|
|
use crate::templater::TemplateRenderer;
|
|
use crate::ui::Ui;
|
|
|
|
/// Compare changes to the repository between two operations
|
|
#[derive(clap::Args, Clone, Debug)]
|
|
pub struct OperationDiffArgs {
|
|
/// Show repository changes in this operation, compared to its parent
|
|
#[arg(
|
|
long,
|
|
visible_alias = "op",
|
|
add = ArgValueCandidates::new(complete::operations),
|
|
)]
|
|
operation: Option<String>,
|
|
/// Show repository changes from this operation
|
|
#[arg(
|
|
long, short,
|
|
conflicts_with = "operation",
|
|
add = ArgValueCandidates::new(complete::operations),
|
|
)]
|
|
from: Option<String>,
|
|
/// Show repository changes to this operation
|
|
#[arg(
|
|
long, short,
|
|
conflicts_with = "operation",
|
|
add = ArgValueCandidates::new(complete::operations),
|
|
)]
|
|
to: Option<String>,
|
|
/// Don't show the graph, show a flat list of modified changes
|
|
#[arg(long)]
|
|
no_graph: bool,
|
|
/// Show patch of modifications to changes
|
|
///
|
|
/// If the previous version has different parents, it will be temporarily
|
|
/// rebased to the parents of the new version, so the diff is not
|
|
/// contaminated by unrelated changes.
|
|
#[arg(long, short = 'p')]
|
|
patch: bool,
|
|
#[command(flatten)]
|
|
diff_format: DiffFormatArgs,
|
|
}
|
|
|
|
pub fn cmd_op_diff(
|
|
ui: &mut Ui,
|
|
command: &CommandHelper,
|
|
args: &OperationDiffArgs,
|
|
) -> Result<(), CommandError> {
|
|
let workspace_command = command.workspace_helper(ui)?;
|
|
let workspace_env = workspace_command.env();
|
|
let repo_loader = workspace_command.workspace().repo_loader();
|
|
let settings = workspace_command.settings();
|
|
let from_op;
|
|
let to_op;
|
|
if args.from.is_some() || args.to.is_some() {
|
|
from_op = workspace_command.resolve_single_op(args.from.as_deref().unwrap_or("@"))?;
|
|
to_op = workspace_command.resolve_single_op(args.to.as_deref().unwrap_or("@"))?;
|
|
} else {
|
|
to_op = workspace_command.resolve_single_op(args.operation.as_deref().unwrap_or("@"))?;
|
|
let to_op_parents: Vec<_> = to_op.parents().try_collect()?;
|
|
from_op = repo_loader.merge_operations(to_op_parents, None)?;
|
|
}
|
|
let graph_style = GraphStyle::from_settings(settings)?;
|
|
let with_content_format = LogContentFormat::new(ui, settings)?;
|
|
|
|
let from_repo = repo_loader.load_at(&from_op)?;
|
|
let to_repo = repo_loader.load_at(&to_op)?;
|
|
|
|
// Create a new transaction starting from `to_repo`.
|
|
let mut tx = to_repo.start_transaction();
|
|
// Merge index from `from_repo` to `to_repo`, so commits in `from_repo` are
|
|
// accessible.
|
|
tx.repo_mut().merge_index(&from_repo);
|
|
let merged_repo = tx.repo();
|
|
|
|
let diff_renderer = {
|
|
let formats = diff_formats_for_log(settings, &args.diff_format, args.patch)?;
|
|
let path_converter = workspace_env.path_converter();
|
|
let conflict_marker_style = workspace_env.conflict_marker_style();
|
|
(!formats.is_empty())
|
|
.then(|| DiffRenderer::new(merged_repo, path_converter, conflict_marker_style, formats))
|
|
};
|
|
let id_prefix_context = workspace_env.new_id_prefix_context();
|
|
let commit_summary_template = {
|
|
let language = workspace_env.commit_template_language(merged_repo, &id_prefix_context);
|
|
let text = settings.get_string("templates.commit_summary")?;
|
|
workspace_env.parse_template(ui, &language, &text, CommitTemplateLanguage::wrap_commit)?
|
|
};
|
|
|
|
let op_summary_template = workspace_command.operation_summary_template();
|
|
ui.request_pager();
|
|
let mut formatter = ui.stdout_formatter();
|
|
write!(formatter, "From operation: ")?;
|
|
op_summary_template.format(&from_op, &mut *formatter)?;
|
|
writeln!(formatter)?;
|
|
write!(formatter, " To operation: ")?;
|
|
op_summary_template.format(&to_op, &mut *formatter)?;
|
|
writeln!(formatter)?;
|
|
|
|
show_op_diff(
|
|
ui,
|
|
formatter.as_mut(),
|
|
merged_repo,
|
|
&from_repo,
|
|
&to_repo,
|
|
&commit_summary_template,
|
|
(!args.no_graph).then_some(graph_style),
|
|
&with_content_format,
|
|
diff_renderer.as_ref(),
|
|
)
|
|
}
|
|
|
|
/// Computes and shows the differences between two operations, using the given
|
|
/// `ReadonlyRepo`s for the operations.
|
|
/// `current_repo` should contain a `Repo` with the indices of both repos merged
|
|
/// into it.
|
|
#[expect(clippy::too_many_arguments)]
|
|
pub fn show_op_diff(
|
|
ui: &Ui,
|
|
formatter: &mut dyn Formatter,
|
|
current_repo: &dyn Repo,
|
|
from_repo: &Arc<ReadonlyRepo>,
|
|
to_repo: &Arc<ReadonlyRepo>,
|
|
commit_summary_template: &TemplateRenderer<Commit>,
|
|
graph_style: Option<GraphStyle>,
|
|
with_content_format: &LogContentFormat,
|
|
diff_renderer: Option<&DiffRenderer>,
|
|
) -> Result<(), CommandError> {
|
|
let changes = compute_operation_commits_diff(current_repo, from_repo, to_repo)?;
|
|
|
|
let commit_id_change_id_map: HashMap<CommitId, ChangeId> = changes
|
|
.iter()
|
|
.flat_map(|(change_id, modified_change)| {
|
|
itertools::chain(
|
|
&modified_change.added_commits,
|
|
&modified_change.removed_commits,
|
|
)
|
|
.map(|commit| (commit.id().clone(), change_id.clone()))
|
|
})
|
|
.collect();
|
|
|
|
let change_parents: HashMap<_, _> = changes
|
|
.iter()
|
|
.map(|(change_id, modified_change)| {
|
|
let parent_change_ids = get_parent_changes(modified_change, &commit_id_change_id_map);
|
|
(change_id.clone(), parent_change_ids)
|
|
})
|
|
.collect();
|
|
|
|
// Order changes in reverse topological order.
|
|
let ordered_change_ids = dag_walk::topo_order_reverse(
|
|
changes.keys().cloned().collect_vec(),
|
|
|change_id: &ChangeId| change_id.clone(),
|
|
|change_id: &ChangeId| change_parents.get(change_id).unwrap().clone(),
|
|
);
|
|
|
|
if !ordered_change_ids.is_empty() {
|
|
writeln!(formatter)?;
|
|
with_content_format.write(formatter, |formatter| {
|
|
writeln!(formatter, "Changed commits:")
|
|
})?;
|
|
if let Some(graph_style) = graph_style {
|
|
let mut raw_output = formatter.raw()?;
|
|
let mut graph = get_graphlog(graph_style, raw_output.as_mut());
|
|
|
|
let graph_iter = TopoGroupedGraphIterator::new(ordered_change_ids.iter().map(
|
|
|change_id| -> Result<_, Infallible> {
|
|
let parent_change_ids = change_parents.get(change_id).unwrap();
|
|
Ok((
|
|
change_id.clone(),
|
|
parent_change_ids
|
|
.iter()
|
|
.map(|parent_change_id| GraphEdge::direct(parent_change_id.clone()))
|
|
.collect_vec(),
|
|
))
|
|
},
|
|
));
|
|
|
|
for node in graph_iter {
|
|
let (change_id, edges) = node.unwrap();
|
|
let modified_change = changes.get(&change_id).unwrap();
|
|
|
|
let mut buffer = vec![];
|
|
let within_graph = with_content_format.sub_width(graph.width(&change_id, &edges));
|
|
within_graph.write(ui.new_formatter(&mut buffer).as_mut(), |formatter| {
|
|
write_modified_change_summary(
|
|
formatter,
|
|
commit_summary_template,
|
|
modified_change,
|
|
)
|
|
})?;
|
|
if !buffer.ends_with(b"\n") {
|
|
buffer.push(b'\n');
|
|
}
|
|
if let Some(diff_renderer) = &diff_renderer {
|
|
let mut formatter = ui.new_formatter(&mut buffer);
|
|
show_change_diff(
|
|
ui,
|
|
formatter.as_mut(),
|
|
diff_renderer,
|
|
modified_change,
|
|
within_graph.width(),
|
|
)?;
|
|
}
|
|
|
|
// TODO: customize node symbol?
|
|
let node_symbol = "○";
|
|
graph.add_node(
|
|
&change_id,
|
|
&edges,
|
|
node_symbol,
|
|
&String::from_utf8_lossy(&buffer),
|
|
)?;
|
|
}
|
|
} else {
|
|
for change_id in ordered_change_ids {
|
|
let modified_change = changes.get(&change_id).unwrap();
|
|
with_content_format.write(formatter, |formatter| {
|
|
write_modified_change_summary(
|
|
formatter,
|
|
commit_summary_template,
|
|
modified_change,
|
|
)
|
|
})?;
|
|
if let Some(diff_renderer) = &diff_renderer {
|
|
let width = with_content_format.width();
|
|
show_change_diff(ui, formatter, diff_renderer, modified_change, width)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let changed_working_copies = diff_named_commit_ids(
|
|
from_repo.view().wc_commit_ids(),
|
|
to_repo.view().wc_commit_ids(),
|
|
)
|
|
.collect_vec();
|
|
if !changed_working_copies.is_empty() {
|
|
writeln!(formatter)?;
|
|
for (name, (from_commit, to_commit)) in changed_working_copies {
|
|
with_content_format.write(formatter, |formatter| {
|
|
// Usually, there is at most one working copy changed per operation, so we put
|
|
// the working copy name in the heading.
|
|
write!(formatter, "Changed working copy ")?;
|
|
write!(formatter.labeled("working_copies"), "{}@", name.as_str())?;
|
|
writeln!(formatter, ":")?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
&RefTarget::resolved(to_commit.cloned()),
|
|
true,
|
|
None,
|
|
)?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
&RefTarget::resolved(from_commit.cloned()),
|
|
false,
|
|
None,
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
|
|
let changed_local_bookmarks = diff_named_ref_targets(
|
|
from_repo.view().local_bookmarks(),
|
|
to_repo.view().local_bookmarks(),
|
|
)
|
|
.collect_vec();
|
|
if !changed_local_bookmarks.is_empty() {
|
|
writeln!(formatter)?;
|
|
with_content_format.write(formatter, |formatter| {
|
|
writeln!(formatter, "Changed local bookmarks:")
|
|
})?;
|
|
for (name, (from_target, to_target)) in changed_local_bookmarks {
|
|
with_content_format.write(formatter, |formatter| {
|
|
writeln!(formatter, "{name}:")?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
to_target,
|
|
true,
|
|
None,
|
|
)?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
from_target,
|
|
false,
|
|
None,
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
|
|
let changed_tags =
|
|
diff_named_ref_targets(from_repo.view().tags(), to_repo.view().tags()).collect_vec();
|
|
if !changed_tags.is_empty() {
|
|
writeln!(formatter)?;
|
|
with_content_format.write(formatter, |formatter| writeln!(formatter, "Changed tags:"))?;
|
|
for (name, (from_target, to_target)) in changed_tags {
|
|
with_content_format.write(formatter, |formatter| {
|
|
writeln!(formatter, "{name}:")?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
to_target,
|
|
true,
|
|
None,
|
|
)?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
from_target,
|
|
false,
|
|
None,
|
|
)
|
|
})?;
|
|
}
|
|
writeln!(formatter)?;
|
|
}
|
|
|
|
let changed_remote_bookmarks = diff_named_remote_refs(
|
|
from_repo.view().all_remote_bookmarks(),
|
|
to_repo.view().all_remote_bookmarks(),
|
|
)
|
|
// Skip updates to the local git repo, since they should typically be covered in
|
|
// local branches.
|
|
.filter(|(symbol, _)| !jj_lib::git::is_special_git_remote(symbol.remote))
|
|
.collect_vec();
|
|
if !changed_remote_bookmarks.is_empty() {
|
|
writeln!(formatter)?;
|
|
with_content_format.write(formatter, |formatter| {
|
|
writeln!(formatter, "Changed remote bookmarks:")
|
|
})?;
|
|
let get_remote_ref_prefix = |remote_ref: &RemoteRef| match remote_ref.state {
|
|
RemoteRefState::New => "untracked",
|
|
RemoteRefState::Tracking => "tracked",
|
|
};
|
|
for (symbol, (from_ref, to_ref)) in changed_remote_bookmarks {
|
|
with_content_format.write(formatter, |formatter| {
|
|
writeln!(formatter, "{symbol}:")?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
&to_ref.target,
|
|
true,
|
|
Some(get_remote_ref_prefix(to_ref)),
|
|
)?;
|
|
write_ref_target_summary(
|
|
formatter,
|
|
current_repo,
|
|
commit_summary_template,
|
|
&from_ref.target,
|
|
false,
|
|
Some(get_remote_ref_prefix(from_ref)),
|
|
)
|
|
})?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes a summary for the given `ModifiedChange`.
|
|
fn write_modified_change_summary(
|
|
formatter: &mut dyn Formatter,
|
|
commit_summary_template: &TemplateRenderer<Commit>,
|
|
modified_change: &ModifiedChange,
|
|
) -> Result<(), std::io::Error> {
|
|
for commit in &modified_change.added_commits {
|
|
formatter.with_label("diff", |formatter| write!(formatter.labeled("added"), "+"))?;
|
|
write!(formatter, " ")?;
|
|
commit_summary_template.format(commit, formatter)?;
|
|
writeln!(formatter)?;
|
|
}
|
|
for commit in &modified_change.removed_commits {
|
|
formatter.with_label("diff", |formatter| {
|
|
write!(formatter.labeled("removed"), "-")
|
|
})?;
|
|
write!(formatter, " ")?;
|
|
commit_summary_template.format(commit, formatter)?;
|
|
writeln!(formatter)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Writes a summary for the given `RefTarget`.
|
|
fn write_ref_target_summary(
|
|
formatter: &mut dyn Formatter,
|
|
repo: &dyn Repo,
|
|
commit_summary_template: &TemplateRenderer<Commit>,
|
|
ref_target: &RefTarget,
|
|
added: bool,
|
|
prefix: Option<&str>,
|
|
) -> Result<(), CommandError> {
|
|
let write_prefix = |formatter: &mut dyn Formatter,
|
|
added: bool,
|
|
prefix: Option<&str>|
|
|
-> Result<(), CommandError> {
|
|
formatter.with_label("diff", |formatter| {
|
|
write!(
|
|
formatter.labeled(if added { "added" } else { "removed" }),
|
|
"{}",
|
|
if added { "+" } else { "-" }
|
|
)
|
|
})?;
|
|
write!(formatter, " ")?;
|
|
if let Some(prefix) = prefix {
|
|
write!(formatter, "{prefix} ")?;
|
|
}
|
|
Ok(())
|
|
};
|
|
if ref_target.is_absent() {
|
|
write_prefix(formatter, added, prefix)?;
|
|
writeln!(formatter, "(absent)")?;
|
|
} else if ref_target.has_conflict() {
|
|
for commit_id in ref_target.added_ids() {
|
|
write_prefix(formatter, added, prefix)?;
|
|
write!(formatter, "(added) ")?;
|
|
let commit = repo.store().get_commit(commit_id)?;
|
|
commit_summary_template.format(&commit, formatter)?;
|
|
writeln!(formatter)?;
|
|
}
|
|
for commit_id in ref_target.removed_ids() {
|
|
write_prefix(formatter, added, prefix)?;
|
|
write!(formatter, "(removed) ")?;
|
|
let commit = repo.store().get_commit(commit_id)?;
|
|
commit_summary_template.format(&commit, formatter)?;
|
|
writeln!(formatter)?;
|
|
}
|
|
} else {
|
|
write_prefix(formatter, added, prefix)?;
|
|
let commit_id = ref_target.as_normal().unwrap();
|
|
let commit = repo.store().get_commit(commit_id)?;
|
|
commit_summary_template.format(&commit, formatter)?;
|
|
writeln!(formatter)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the change IDs of the parents of the given `modified_change`, which
|
|
/// are the parents of all newly added commits for the change, or the parents of
|
|
/// all removed commits if there are no added commits.
|
|
fn get_parent_changes(
|
|
modified_change: &ModifiedChange,
|
|
commit_id_change_id_map: &HashMap<CommitId, ChangeId>,
|
|
) -> Vec<ChangeId> {
|
|
// TODO: how should we handle multiple added or removed commits?
|
|
if !modified_change.added_commits.is_empty() {
|
|
modified_change
|
|
.added_commits
|
|
.iter()
|
|
.flat_map(|commit| commit.parent_ids())
|
|
.filter_map(|parent_id| commit_id_change_id_map.get(parent_id).cloned())
|
|
.unique()
|
|
.collect_vec()
|
|
} else {
|
|
modified_change
|
|
.removed_commits
|
|
.iter()
|
|
.flat_map(|commit| commit.parent_ids())
|
|
.filter_map(|parent_id| commit_id_change_id_map.get(parent_id).cloned())
|
|
.unique()
|
|
.collect_vec()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct ModifiedChange {
|
|
added_commits: Vec<Commit>,
|
|
removed_commits: Vec<Commit>,
|
|
}
|
|
|
|
/// Compute the changes in commits between two operations, returned as a
|
|
/// `HashMap` from `ChangeId` to a `ModifiedChange` struct containing the added
|
|
/// and removed commits for the change ID.
|
|
fn compute_operation_commits_diff(
|
|
repo: &dyn Repo,
|
|
from_repo: &ReadonlyRepo,
|
|
to_repo: &ReadonlyRepo,
|
|
) -> Result<IndexMap<ChangeId, ModifiedChange>, CommandError> {
|
|
let mut changes: IndexMap<ChangeId, ModifiedChange> = IndexMap::new();
|
|
|
|
let from_heads = from_repo.view().heads().iter().cloned().collect_vec();
|
|
let to_heads = to_repo.view().heads().iter().cloned().collect_vec();
|
|
|
|
// Find newly added commits in `to_repo` which were not present in
|
|
// `from_repo`.
|
|
for commit in revset::walk_revs(repo, &to_heads, &from_heads)?
|
|
.iter()
|
|
.commits(repo.store())
|
|
{
|
|
let commit = commit?;
|
|
let modified_change = changes
|
|
.entry(commit.change_id().clone())
|
|
.or_insert_with(|| ModifiedChange {
|
|
added_commits: vec![],
|
|
removed_commits: vec![],
|
|
});
|
|
modified_change.added_commits.push(commit);
|
|
}
|
|
|
|
// Find commits which were hidden in `to_repo`.
|
|
for commit in revset::walk_revs(repo, &from_heads, &to_heads)?
|
|
.iter()
|
|
.commits(repo.store())
|
|
{
|
|
let commit = commit?;
|
|
let modified_change = changes
|
|
.entry(commit.change_id().clone())
|
|
.or_insert_with(|| ModifiedChange {
|
|
added_commits: vec![],
|
|
removed_commits: vec![],
|
|
});
|
|
modified_change.removed_commits.push(commit);
|
|
}
|
|
|
|
Ok(changes)
|
|
}
|
|
|
|
/// Displays the diffs of a modified change. The output differs based on the
|
|
/// commits added and removed for the change.
|
|
/// If there is a single added and removed commit, the diff is shown between the
|
|
/// removed commit and the added commit rebased onto the removed commit's
|
|
/// parents. If there is only a single added or single removed commit, the diff
|
|
/// is shown of that commit's contents.
|
|
fn show_change_diff(
|
|
ui: &Ui,
|
|
formatter: &mut dyn Formatter,
|
|
diff_renderer: &DiffRenderer,
|
|
change: &ModifiedChange,
|
|
width: usize,
|
|
) -> Result<(), CommandError> {
|
|
match (&*change.removed_commits, &*change.added_commits) {
|
|
(predecessors @ ([] | [_]), [commit]) => {
|
|
// New or modified change. If the modification involved a rebase,
|
|
// show diffs from the rebased tree.
|
|
diff_renderer.show_inter_diff(
|
|
ui,
|
|
formatter,
|
|
predecessors,
|
|
commit,
|
|
&EverythingMatcher,
|
|
width,
|
|
)?;
|
|
}
|
|
([commit], []) => {
|
|
// TODO: Should we show a reverse diff?
|
|
diff_renderer.show_patch(ui, formatter, commit, &EverythingMatcher, width)?;
|
|
}
|
|
([_, _, ..], _) | (_, [_, _, ..]) => {}
|
|
([], []) => panic!("ModifiedChange should have at least one entry"),
|
|
}
|
|
Ok(())
|
|
}
|