Skip to content

Commit

Permalink
cli: add jj operation diff command
Browse files Browse the repository at this point in the history
  • Loading branch information
bnjmnt4n committed May 3, 2024
1 parent 19563fe commit 7d1a07e
Show file tree
Hide file tree
Showing 3 changed files with 399 additions and 3 deletions.
350 changes: 347 additions & 3 deletions cli/src/commands/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,31 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use std::collections::HashMap;
use std::io::Write as _;
use std::slice;

use clap::Subcommand;
use indexmap::IndexMap;
use itertools::Itertools as _;
use jj_lib::backend::{BackendResult, ChangeId, CommitId};
use jj_lib::commit::Commit;
use jj_lib::matchers::EverythingMatcher;
use jj_lib::object_id::ObjectId;
use jj_lib::op_store::OperationId;
use jj_lib::op_walk;
use jj_lib::operation::Operation;
use jj_lib::repo::Repo;
use jj_lib::repo::{MutableRepo, Repo};
use jj_lib::revset::RevsetIteratorExt;
use jj_lib::rewrite::{merge_commit_trees, rebase_to_dest_parent};
use jj_lib::{dag_walk, op_walk, revset};

use crate::cli_util::{format_template, short_operation_hash, CommandHelper, LogContentFormat};
use crate::cli_util::{
format_template, short_change_hash, short_operation_hash, CommandHelper, LogContentFormat,
WorkspaceCommandHelper,
};
use crate::command_error::{user_error, user_error_with_hint, CommandError};
use crate::diff_util::{self, DiffFormat, DiffFormatArgs};
use crate::formatter::Formatter;
use crate::graphlog::{get_graphlog, Edge};
use crate::operation_templater::OperationTemplateLanguage;
use crate::ui::Ui;
Expand All @@ -36,6 +48,7 @@ use crate::ui::Ui;
#[derive(Subcommand, Clone, Debug)]
pub enum OperationCommand {
Abandon(OperationAbandonArgs),
Diff(OperationDiffArgs),
Log(OperationLogArgs),
Undo(OperationUndoArgs),
Restore(OperationRestoreArgs),
Expand Down Expand Up @@ -122,6 +135,32 @@ enum UndoWhatToRestore {
RemoteTracking,
}

/// 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)]
operation: Option<String>,
/// Show repository changes from this operation
#[arg(long, conflicts_with = "operation")]
from: Option<String>,
/// Show repository changes to this operation
#[arg(long, conflicts_with = "operation")]
to: Option<String>,
/// Don't show the graph, show a flat list of revisions
#[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,
}

const DEFAULT_UNDO_WHAT: [UndoWhatToRestore; 2] =
[UndoWhatToRestore::Repo, UndoWhatToRestore::RemoteTracking];

Expand Down Expand Up @@ -395,13 +434,318 @@ fn cmd_op_abandon(
Ok(())
}

fn cmd_op_diff(
ui: &mut Ui,
command: &CommandHelper,
args: &OperationDiffArgs,
) -> Result<(), CommandError> {
// TODO: Should we load the repo here?
let workspace = command.load_workspace()?;
let repo_loader = workspace.repo_loader();
// TODO: Should we use `--at-operation` instead of `--operation`?
let head_op_str = &command.global_args().at_operation;
if head_op_str != "@" {
return Err(user_error("--at-op is not respected"));
}
let from_op;
let to_op;
if args.from.is_some() || args.to.is_some() {
from_op =
op_walk::resolve_op_for_load(repo_loader, args.from.as_ref().unwrap_or(head_op_str))?;
to_op = op_walk::resolve_op_for_load(repo_loader, args.to.as_ref().unwrap_or(head_op_str))?;
} else {
to_op = op_walk::resolve_op_for_load(
repo_loader,
args.operation.as_ref().unwrap_or(head_op_str),
)?;
let mut parent_ops = to_op.parents();
if parent_ops.len() > 1 {
return Err(user_error("Cannot perform diff on a merge operation"));
}
from_op = parent_ops.next().transpose()?.unwrap();
}
let diff_formats =
diff_util::diff_formats_for_log(command.settings(), &args.diff_format, args.patch)?;
let with_content_format = LogContentFormat::new(ui, command.settings())?;

let workspace = command.load_workspace()?;
let repo_loader = workspace.repo_loader();
let from_repo = repo_loader.load_at(&from_op)?;
let to_repo = repo_loader.load_at(&to_op)?;
let mut mut_repo = MutableRepo::new(
from_repo.clone(),
from_repo.readonly_index(),
from_repo.view(),
);
mut_repo.merge_index(&to_repo);

let from_heads = from_repo.view().heads().iter().cloned().collect_vec();
let to_heads = to_repo.view().heads().iter().cloned().collect_vec();
let changes = compute_operation_commits_diff(&mut_repo, &from_heads, &to_heads)?;

let commit_id_change_id_map: HashMap<CommitId, ChangeId> = changes
.iter()
.flat_map(|(change_id, modified_change)| {
modified_change
.added_commits
.iter()
.map(|commit| (commit.id().clone(), change_id.clone()))
.chain(
modified_change
.removed_commits
.iter()
.map(|commit| (commit.id().clone(), change_id.clone())),
)
})
.collect();

// TODO: this ordering probably can be improved, in particular when showing the
// results of a `git fetch` operation.
let ordered_changes = dag_walk::topo_order_reverse(
changes.keys().cloned().collect_vec(),
|change_id: &ChangeId| change_id.clone(),
|change_id: &ChangeId| {
let modified_change = changes.get(change_id).unwrap();
get_parent_changes(modified_change, &commit_id_change_id_map)
},
);

// TODO: Probably should use `mut_repo`, but this only accepts a `ReadonlyRepo`.
let workspace_command =
command.for_loaded_repo(ui, command.load_workspace()?, to_repo.clone())?;

ui.request_pager();
let mut formatter = ui.stdout_formatter();
let formatter = formatter.as_mut();

writeln!(
formatter,
"From operation {}: {}",
short_operation_hash(from_op.id()),
from_op.metadata().description,
)?;
writeln!(
formatter,
" To operation {}: {}",
short_operation_hash(to_op.id()),
to_op.metadata().description,
)?;
writeln!(formatter)?;

let write_modified_change_summary = |formatter: &mut dyn Formatter,
change_id: &ChangeId,
modified_change: &ModifiedChange|
-> Result<(), std::io::Error> {
formatter.with_label("diff", |formatter| {
writeln!(
formatter.labeled("hunk_header"),
"Modified change {}",
short_change_hash(change_id)
)?;
Ok(())
})?;
for commit in modified_change.added_commits.iter() {
write!(formatter, "+")?;
workspace_command.write_commit_summary(formatter, commit)?;
writeln!(formatter)?;
}
for commit in modified_change.removed_commits.iter() {
write!(formatter, "-")?;
workspace_command.write_commit_summary(formatter, commit)?;
writeln!(formatter)?;
}
Ok(())
};

if !args.no_graph {
let mut graph = get_graphlog(command.settings(), formatter.raw());
for change_id in ordered_changes.iter() {
let modified_change = changes.get(change_id).unwrap();

let mut edges = vec![];
for parent_change_id in get_parent_changes(modified_change, &commit_id_change_id_map) {
edges.push(Edge::Direct(parent_change_id));
}

let mut buffer = vec![];
with_content_format.write_graph_text(
ui.new_formatter(&mut buffer).as_mut(),
|formatter| write_modified_change_summary(formatter, change_id, modified_change),
|| graph.width(change_id, &edges),
)?;
if !buffer.ends_with(b"\n") {
buffer.push(b'\n');
}
if !diff_formats.is_empty() {
let mut formatter = ui.new_formatter(&mut buffer);
show_predecessor_patch(
ui,
formatter.as_mut(),
&workspace_command,
modified_change,
&diff_formats,
)?;
}
buffer.push(b'\n');

let node_symbol = "○";
graph.add_node(
change_id,
&edges,
node_symbol,
&String::from_utf8_lossy(&buffer),
)?;
}
} else {
for change_id in ordered_changes.iter() {
let modified_change = changes.get(change_id).unwrap();

write_modified_change_summary(formatter, change_id, modified_change)?;
if !diff_formats.is_empty() {
show_predecessor_patch(
ui,
formatter,
&workspace_command,
modified_change,
&diff_formats,
)?;
}
writeln!(formatter)?;
}
}

Ok(())
}

fn get_parent_changes(
modified_change: &ModifiedChange,
commit_id_change_id_map: &HashMap<CommitId, ChangeId>,
) -> Vec<ChangeId> {
modified_change
.added_commits
.iter()
.flat_map(|commit| commit.parent_ids())
.chain(
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>,
}

fn compute_operation_commits_diff(
repo: &MutableRepo,
from_heads: &[CommitId],
to_heads: &[CommitId],
) -> BackendResult<IndexMap<ChangeId, ModifiedChange>> {
let mut changes: IndexMap<ChangeId, ModifiedChange> = IndexMap::new();
for commit in revset::walk_revs(repo, from_heads, to_heads)
.unwrap()
.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);
}

for commit in revset::walk_revs(repo, to_heads, from_heads)
.unwrap()
.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);
}

Ok(changes)
}

fn show_predecessor_patch(
ui: &Ui,
formatter: &mut dyn Formatter,
workspace_command: &WorkspaceCommandHelper,
modified_change: &ModifiedChange,
diff_formats: &[DiffFormat],
) -> Result<(), CommandError> {
// TODO: how should we handle multiple added or removed commits?
// Alternatively, use `predecessors`?
if modified_change.added_commits.len() == 1 && modified_change.removed_commits.len() == 1 {
let commit = &modified_change.added_commits[0];
let predecessor = &modified_change.removed_commits[0];
let predecessor_tree =
rebase_to_dest_parent(workspace_command.repo().as_ref(), predecessor, commit)?;
let tree = commit.tree()?;
diff_util::show_diff(
ui,
formatter,
workspace_command,
&predecessor_tree,
&tree,
&EverythingMatcher,
diff_formats,
)?;
}
// TODO: Should we even show a diff for added or removed commits?
else if modified_change.added_commits.len() == 1 {
let commit = &modified_change.added_commits[0];
let parent_tree = merge_commit_trees(workspace_command.repo().as_ref(), &commit.parents())?;
let tree = commit.tree()?;
diff_util::show_diff(
ui,
formatter,
workspace_command,
&parent_tree,
&tree,
&EverythingMatcher,
diff_formats,
)?;
} else if modified_change.removed_commits.len() == 1 {
let commit = &modified_change.removed_commits[0];
let parent_tree = merge_commit_trees(workspace_command.repo().as_ref(), &commit.parents())?;
let tree = commit.tree()?;
diff_util::show_diff(
ui,
formatter,
workspace_command,
&parent_tree,
&tree,
&EverythingMatcher,
diff_formats,
)?;
}

Ok(())
}

pub fn cmd_operation(
ui: &mut Ui,
command: &CommandHelper,
subcommand: &OperationCommand,
) -> Result<(), CommandError> {
match subcommand {
OperationCommand::Abandon(args) => cmd_op_abandon(ui, command, args),
OperationCommand::Diff(args) => cmd_op_diff(ui, command, args),
OperationCommand::Log(args) => cmd_op_log(ui, command, args),
OperationCommand::Restore(args) => cmd_op_restore(ui, command, args),
OperationCommand::Undo(args) => cmd_op_undo(ui, command, args),
Expand Down
Loading

0 comments on commit 7d1a07e

Please sign in to comment.