diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3a549f90..d486b746ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * New `diff_contains()` revset function can be used to search diffs. +* New command `jj operation diff` that can compare changes made between two + operations. + +* New command `jj operation show` that can show the changes made in a single + operation. + ### Fixed bugs * `jj diff --git` no longer shows the contents of binary files. diff --git a/cli/src/commands/operation/diff.rs b/cli/src/commands/operation/diff.rs new file mode 100644 index 0000000000..61a1f309a9 --- /dev/null +++ b/cli/src/commands/operation/diff.rs @@ -0,0 +1,563 @@ +// 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::sync::Arc; + +use indexmap::IndexMap; +use itertools::Itertools; +use jj_lib::backend::{ChangeId, CommitId}; +use jj_lib::commit::Commit; +use jj_lib::git::REMOTE_NAME_FOR_LOCAL_GIT_REPO; +use jj_lib::graph::{GraphEdge, TopoGroupedGraphIterator}; +use jj_lib::matchers::EverythingMatcher; +use jj_lib::op_store::{RefTarget, RemoteRef, RemoteRefState}; +use jj_lib::refs::{diff_named_ref_targets, diff_named_remote_refs}; +use jj_lib::repo::{ReadonlyRepo, Repo}; +use jj_lib::revset::RevsetIteratorExt as _; +use jj_lib::rewrite::rebase_to_dest_parent; +use jj_lib::{dag_walk, revset}; + +use crate::cli_util::{short_change_hash, short_operation_hash, CommandHelper, LogContentFormat}; +use crate::command_error::{user_error, CommandError}; +use crate::diff_util::{DiffFormatArgs, DiffRenderer}; +use crate::formatter::Formatter; +use crate::graphlog::{get_graphlog, Edge}; +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")] + operation: Option, + /// Show repository changes from this operation + #[arg(long, conflicts_with = "operation")] + from: Option, + /// Show repository changes to this operation + #[arg(long, conflicts_with = "operation")] + to: Option, + /// 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 repo = workspace_command.repo(); + let repo_loader = &repo.loader(); + 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()?; + if to_op_parents.is_empty() { + return Err(user_error("Cannot diff operation with no parents")); + } + from_op = repo_loader.merge_operations(command.settings(), to_op_parents, None)?; + } + let with_content_format = LogContentFormat::new(ui, command.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 workspace_command = + command.for_loaded_repo(ui, command.load_workspace()?, to_repo.clone())?; + let mut tx = workspace_command.start_transaction(); + // Merge index from `from_repo` to `to_repo`, so commits in `from_repo` are + // accessible. + tx.mut_repo().merge_index(&from_repo); + let diff_renderer = tx + .base_workspace_helper() + .diff_renderer_for_log(&args.diff_format, args.patch)?; + let commit_summary_template = tx.commit_summary_template(); + + ui.request_pager(); + ui.stdout_formatter().with_label("op_log", |formatter| { + write!(formatter, "From operation ")?; + write!( + formatter.labeled("id"), + "{}", + short_operation_hash(from_op.id()), + )?; + write!(formatter, ": ")?; + write!( + formatter.labeled("description"), + "{}", + if from_op.id() == from_op.op_store().root_operation_id() { + "root()" + } else { + &from_op.metadata().description + } + )?; + writeln!(formatter)?; + write!(formatter, " To operation ")?; + write!( + formatter.labeled("id"), + "{}", + short_operation_hash(to_op.id()), + )?; + write!(formatter, ": ")?; + write!( + formatter.labeled("description"), + "{}", + if to_op.id() == to_op.op_store().root_operation_id() { + "root()" + } else { + &to_op.metadata().description + } + )?; + writeln!(formatter)?; + Ok(()) + })?; + + show_op_diff( + ui, + command, + tx.repo(), + &from_repo, + &to_repo, + &commit_summary_template, + !args.no_graph, + &with_content_format, + diff_renderer, + ) +} + +/// 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. +#[allow(clippy::too_many_arguments)] +pub fn show_op_diff( + ui: &mut Ui, + command: &CommandHelper, + current_repo: &dyn Repo, + from_repo: &Arc, + to_repo: &Arc, + commit_summary_template: &TemplateRenderer, + show_graph: bool, + with_content_format: &LogContentFormat, + diff_renderer: Option, +) -> Result<(), CommandError> { + let changes = compute_operation_commits_diff(current_repo, from_repo, to_repo)?; + + let commit_id_change_id_map: HashMap = 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(), + ); + + let mut formatter = ui.stdout_formatter(); + let formatter = formatter.as_mut(); + + if !ordered_change_ids.is_empty() { + writeln!(formatter)?; + writeln!(formatter, "Changed commits:")?; + if show_graph { + let mut graph = get_graphlog(command.settings(), formatter.raw()); + + let graph_iter = + TopoGroupedGraphIterator::new(ordered_change_ids.iter().map(|change_id| { + let parent_change_ids = change_parents.get(change_id).unwrap(); + ( + change_id.clone(), + parent_change_ids + .iter() + .map(|parent_change_id| GraphEdge::direct(parent_change_id.clone())) + .collect_vec(), + ) + })); + + for (change_id, edges) in graph_iter { + let modified_change = changes.get(&change_id).unwrap(); + let edges = edges + .iter() + .map(|edge| Edge::Direct(edge.target.clone())) + .collect_vec(); + + let mut buffer = vec![]; + with_content_format.write_graph_text( + ui.new_formatter(&mut buffer).as_mut(), + |formatter| { + write_modified_change_summary( + formatter, + commit_summary_template, + &change_id, + modified_change, + ) + }, + || graph.width(&change_id, &edges), + )?; + 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(), + current_repo, + diff_renderer, + modified_change, + )?; + } + + // 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(); + write_modified_change_summary( + formatter, + commit_summary_template, + &change_id, + modified_change, + )?; + if let Some(diff_renderer) = &diff_renderer { + show_change_diff(ui, formatter, current_repo, diff_renderer, modified_change)?; + } + } + } + } + + let changed_local_branches = diff_named_ref_targets( + from_repo.view().local_branches(), + to_repo.view().local_branches(), + ) + .collect_vec(); + if !changed_local_branches.is_empty() { + writeln!(formatter)?; + writeln!(formatter, "Changed local branches:")?; + for (name, (from_target, to_target)) in changed_local_branches { + 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)?; + writeln!(formatter, "Changed tags:")?; + for (name, (from_target, to_target)) in changed_tags { + 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_branches = diff_named_remote_refs( + from_repo.view().all_remote_branches(), + to_repo.view().all_remote_branches(), + ) + // Skip updates to the local git repo, since they should typically be covered in + // local branches. + .filter(|((_, remote_name), _)| *remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO) + .collect_vec(); + if !changed_remote_branches.is_empty() { + writeln!(formatter)?; + writeln!(formatter, "Changed remote branches:")?; + let get_remote_ref_prefix = |remote_ref: &RemoteRef| match remote_ref.state { + RemoteRefState::New => "untracked", + RemoteRefState::Tracking => "tracked", + }; + for ((name, remote_name), (from_ref, to_ref)) in changed_remote_branches { + writeln!(formatter, "{}@{}:", name, remote_name)?; + 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, + change_id: &ChangeId, + modified_change: &ModifiedChange, +) -> Result<(), std::io::Error> { + writeln!(formatter, "Change {}", short_change_hash(change_id))?; + for commit in modified_change.added_commits.iter() { + 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.iter() { + 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, + 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, +) -> Vec { + // 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, + removed_commits: Vec, +} + +/// 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, CommandError> { + let mut changes: IndexMap = 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, + repo: &dyn Repo, + diff_renderer: &DiffRenderer, + modified_change: &ModifiedChange, +) -> Result<(), CommandError> { + 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(repo, predecessor, commit)?; + let tree = commit.tree()?; + diff_renderer.show_diff(ui, formatter, &predecessor_tree, &tree, &EverythingMatcher)?; + } else if modified_change.added_commits.len() == 1 { + let commit = &modified_change.added_commits[0]; + diff_renderer.show_patch(ui, formatter, commit, &EverythingMatcher)?; + } else if modified_change.removed_commits.len() == 1 { + // TODO: Should we show a reverse diff? + let commit = &modified_change.removed_commits[0]; + diff_renderer.show_patch(ui, formatter, commit, &EverythingMatcher)?; + } + + Ok(()) +} diff --git a/cli/src/commands/operation/mod.rs b/cli/src/commands/operation/mod.rs index f28b404df8..aef1925c7f 100644 --- a/cli/src/commands/operation/mod.rs +++ b/cli/src/commands/operation/mod.rs @@ -13,14 +13,18 @@ // limitations under the License. mod abandon; +mod diff; mod log; mod restore; +mod show; pub mod undo; use abandon::{cmd_op_abandon, OperationAbandonArgs}; use clap::Subcommand; +use diff::{cmd_op_diff, OperationDiffArgs}; use log::{cmd_op_log, OperationLogArgs}; use restore::{cmd_op_restore, OperationRestoreArgs}; +use show::{cmd_op_show, OperationShowArgs}; use undo::{cmd_op_undo, OperationUndoArgs}; use crate::cli_util::CommandHelper; @@ -34,8 +38,10 @@ use crate::ui::Ui; #[derive(Subcommand, Clone, Debug)] pub enum OperationCommand { Abandon(OperationAbandonArgs), + Diff(OperationDiffArgs), Log(OperationLogArgs), Restore(OperationRestoreArgs), + Show(OperationShowArgs), Undo(OperationUndoArgs), } @@ -46,8 +52,10 @@ pub fn cmd_operation( ) -> 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::Show(args) => cmd_op_show(ui, command, args), OperationCommand::Undo(args) => cmd_op_undo(ui, command, args), } } diff --git a/cli/src/commands/operation/show.rs b/cli/src/commands/operation/show.rs new file mode 100644 index 0000000000..1546d1b53b --- /dev/null +++ b/cli/src/commands/operation/show.rs @@ -0,0 +1,96 @@ +// 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 itertools::Itertools; + +use super::diff::show_op_diff; +use crate::cli_util::{CommandHelper, LogContentFormat}; +use crate::command_error::{user_error, CommandError}; +use crate::diff_util::DiffFormatArgs; +use crate::operation_templater::OperationTemplateLanguage; +use crate::ui::Ui; + +/// Show changes to the repository in an operation +#[derive(clap::Args, Clone, Debug)] +pub struct OperationShowArgs { + /// Show repository changes in this operation, compared to its parent(s) + #[arg(visible_alias = "op", default_value = "@")] + operation: 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_show( + ui: &mut Ui, + command: &CommandHelper, + args: &OperationShowArgs, +) -> Result<(), CommandError> { + let workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo(); + let current_op_id = repo.operation().id(); + let repo_loader = &repo.loader(); + let op = workspace_command.resolve_single_op(&args.operation)?; + let parents: Vec<_> = op.parents().try_collect()?; + if parents.is_empty() { + return Err(user_error("Cannot show the root operation")); + } + let parent_op = repo_loader.merge_operations(command.settings(), parents, None)?; + let parent_repo = repo_loader.load_at(&parent_op)?; + let repo = repo_loader.load_at(&op)?; + + let workspace_command = command.for_loaded_repo(ui, command.load_workspace()?, repo.clone())?; + let commit_summary_template = workspace_command.commit_summary_template(); + + let with_content_format = LogContentFormat::new(ui, command.settings())?; + let diff_renderer = workspace_command.diff_renderer_for_log(&args.diff_format, args.patch)?; + + // TODO: Should we make this customizable via clap arg? + let template; + { + let language = OperationTemplateLanguage::new( + repo_loader.op_store().root_operation_id(), + Some(current_op_id), + command.operation_template_extensions(), + ); + let text = command.settings().config().get_string("templates.op_log")?; + template = workspace_command + .parse_template(&language, &text, OperationTemplateLanguage::wrap_operation)? + .labeled("op_log"); + } + + ui.request_pager(); + template.format(&op, ui.stdout_formatter().as_mut())?; + + show_op_diff( + ui, + command, + repo.as_ref(), + &parent_repo, + &repo, + &commit_summary_template, + !args.no_graph, + &with_content_format, + diff_renderer, + ) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 66ab5779ae..cef02c2fcb 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -61,8 +61,10 @@ This document contains the help content for the `jj` command-line program. * [`jj obslog`↴](#jj-obslog) * [`jj operation`↴](#jj-operation) * [`jj operation abandon`↴](#jj-operation-abandon) +* [`jj operation diff`↴](#jj-operation-diff) * [`jj operation log`↴](#jj-operation-log) * [`jj operation restore`↴](#jj-operation-restore) +* [`jj operation show`↴](#jj-operation-show) * [`jj operation undo`↴](#jj-operation-undo) * [`jj parallelize`↴](#jj-parallelize) * [`jj prev`↴](#jj-prev) @@ -1238,8 +1240,10 @@ For information about the operation log, see https://github.com/martinvonz/jj/bl ###### **Subcommands:** * `abandon` — Abandon operation history +* `diff` — Compare changes to the repository between two operations * `log` — Show the operation log * `restore` — Create a new operation that restores the repo to an earlier state +* `show` — Show changes to the repository in an operation * `undo` — Create a new operation that undoes an earlier operation @@ -1262,6 +1266,36 @@ The abandoned operations, commits, and other unreachable objects can later be ga +## `jj operation diff` + +Compare changes to the repository between two operations + +**Usage:** `jj operation diff [OPTIONS]` + +###### **Options:** + +* `--operation ` — Show repository changes in this operation, compared to its parent +* `--from ` — Show repository changes from this operation +* `--to ` — Show repository changes to this operation +* `--no-graph` — Don't show the graph, show a flat list of modified changes +* `-p`, `--patch` — 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. +* `-s`, `--summary` — For each path, show only whether it was modified, added, or deleted +* `--stat` — Show a histogram of the changes +* `--types` — For each path, show only its type before and after + + The diff is shown as two letters. The first letter indicates the type before and the second letter indicates the type after. '-' indicates that the path was not present, 'F' represents a regular file, `L' represents a symlink, 'C' represents a conflict, and 'G' represents a Git submodule. +* `--name-only` — For each path, show only its path + + Typically useful for shell commands like: `jj diff -r @- --name_only | xargs perl -pi -e's/OLD/NEW/g` +* `--git` — Show a Git-format diff +* `--color-words` — Show a word-level diff with changes indicated only by color +* `--tool ` — Generate diff by external command +* `--context ` — Number of lines of context to show + + + ## `jj operation log` Show the operation log @@ -1309,6 +1343,39 @@ This restores the repo to the state at the specified operation, effectively undo +## `jj operation show` + +Show changes to the repository in an operation + +**Usage:** `jj operation show [OPTIONS] [OPERATION]` + +###### **Arguments:** + +* `` — Show repository changes in this operation, compared to its parent(s) + + Default value: `@` + +###### **Options:** + +* `--no-graph` — Don't show the graph, show a flat list of modified changes +* `-p`, `--patch` — 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. +* `-s`, `--summary` — For each path, show only whether it was modified, added, or deleted +* `--stat` — Show a histogram of the changes +* `--types` — For each path, show only its type before and after + + The diff is shown as two letters. The first letter indicates the type before and the second letter indicates the type after. '-' indicates that the path was not present, 'F' represents a regular file, `L' represents a symlink, 'C' represents a conflict, and 'G' represents a Git submodule. +* `--name-only` — For each path, show only its path + + Typically useful for shell commands like: `jj diff -r @- --name_only | xargs perl -pi -e's/OLD/NEW/g` +* `--git` — Show a Git-format diff +* `--color-words` — Show a word-level diff with changes indicated only by color +* `--tool ` — Generate diff by external command +* `--context ` — Number of lines of context to show + + + ## `jj operation undo` Create a new operation that undoes an earlier operation diff --git a/cli/tests/test_operations.rs b/cli/tests/test_operations.rs index 9fcaa5dff7..86d1fbf27b 100644 --- a/cli/tests/test_operations.rs +++ b/cli/tests/test_operations.rs @@ -507,6 +507,1000 @@ fn test_op_abandon_without_updating_working_copy() { "###); } +#[test] +fn test_op_diff() { + let test_env = TestEnvironment::default(); + let git_repo_path = test_env.env_root().join("git-repo"); + let git_repo = init_bare_git_repo(&git_repo_path); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "git-repo", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + // Overview of op log. + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "log"]); + insta::assert_snapshot!(&stdout, @r###" + @ 984d5ceb039f test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ check out git remote's default branch + │ args: jj git clone git-repo repo + ○ 817baaeefcbb test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ fetch from git remote into empty repo + │ args: jj git clone git-repo repo + ○ b51416386f26 test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ add workspace 'default' + ○ 9a7d829846af test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ initialize repo + ○ 000000000000 root() + "###); + + // Diff between the same operation should be empty. + let stdout = test_env.jj_cmd_success( + &repo_path, + &["op", "diff", "--from", "0000000", "--to", "0000000"], + ); + insta::assert_snapshot!(&stdout, @r###" + From operation 000000000000: root() + To operation 000000000000: root() + + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "--from", "@", "--to", "@"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 984d5ceb039f: check out git remote's default branch + To operation 984d5ceb039f: check out git remote's default branch + "###); + + // Diff from parent operation to latest operation. + // `jj op diff --op @` should behave identically to `jj op diff --from + // @- --to @` (if `@` is not a merge commit). + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "--from", "@-", "--to", "@"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 817baaeefcbb: fetch from git remote into empty repo + To operation 984d5ceb039f: check out git remote's default branch + + Changed commits: + ○ Change sqpuoqvxutmz + + sqpuoqvx 9708515f (empty) (no description set) + ○ Change qpvuntsmwlqt + - qpvuntsm hidden 230dd059 (empty) (no description set) + + Changed local branches: + branch-1: + + ulyvmwyz 1d843d1f branch-1 | Commit 1 + - (absent) + + Changed remote branches: + branch-1@origin: + + tracked ulyvmwyz 1d843d1f branch-1 | Commit 1 + - untracked ulyvmwyz 1d843d1f branch-1 | Commit 1 + "###); + let stdout_without_from_to = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + assert_eq!(stdout, stdout_without_from_to); + + // Diff from root operation to latest operation + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "--from", "0000000"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 000000000000: root() + To operation 984d5ceb039f: check out git remote's default branch + + Changed commits: + ○ Change sqpuoqvxutmz + │ + sqpuoqvx 9708515f (empty) (no description set) + ○ Change ulyvmwyzwuwt + │ + ulyvmwyz 1d843d1f branch-1 | Commit 1 + │ ○ Change tqyxmsztkvot + ├─╯ + tqyxmszt 3e785984 branch-3@origin | Commit 3 + │ ○ Change yuvsmzqkmpws + ├─╯ + yuvsmzqk 3d9189bc branch-2@origin | Commit 2 + ○ Change zzzzzzzzzzzz + + zzzzzzzz 00000000 (empty) (no description set) + + Changed local branches: + branch-1: + + ulyvmwyz 1d843d1f branch-1 | Commit 1 + - (absent) + + Changed remote branches: + branch-1@origin: + + tracked ulyvmwyz 1d843d1f branch-1 | Commit 1 + - untracked (absent) + branch-2@origin: + + untracked yuvsmzqk 3d9189bc branch-2@origin | Commit 2 + - untracked (absent) + branch-3@origin: + + untracked tqyxmszt 3e785984 branch-3@origin | Commit 3 + - untracked (absent) + "###); + + // Diff from latest operation to root operation + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "--to", "0000000"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 984d5ceb039f: check out git remote's default branch + To operation 000000000000: root() + + Changed commits: + ○ Change sqpuoqvxutmz + │ - sqpuoqvx hidden 9708515f (empty) (no description set) + ○ Change ulyvmwyzwuwt + │ - ulyvmwyz hidden 1d843d1f Commit 1 + │ ○ Change tqyxmsztkvot + ├─╯ - tqyxmszt hidden 3e785984 Commit 3 + │ ○ Change yuvsmzqkmpws + ├─╯ - yuvsmzqk hidden 3d9189bc Commit 2 + ○ Change zzzzzzzzzzzz + - zzzzzzzz hidden 00000000 (empty) (no description set) + + Changed local branches: + branch-1: + + (absent) + - ulyvmwyz hidden 1d843d1f Commit 1 + + Changed remote branches: + branch-1@origin: + + untracked (absent) + - tracked ulyvmwyz hidden 1d843d1f Commit 1 + branch-2@origin: + + untracked (absent) + - untracked yuvsmzqk hidden 3d9189bc Commit 2 + branch-3@origin: + + untracked (absent) + - untracked tqyxmszt hidden 3e785984 Commit 3 + "###); + + // Create a conflicted branch using a concurrent operation. + test_env.jj_cmd_ok( + &repo_path, + &[ + "branch", + "set", + "branch-1", + "-r", + "branch-2@origin", + "--at-op", + "@-", + ], + ); + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["log"]); + insta::assert_snapshot!(&stderr, @r###" + Concurrent modification detected, resolving automatically. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "log"]); + insta::assert_snapshot!(&stdout, @r###" + @ 6eeb006eccd0 test-username@host.example.com 2001-02-03 04:05:16.000 +07:00 - 2001-02-03 04:05:16.000 +07:00 + ├─╮ resolve concurrent operations + │ │ args: jj log + ○ │ 984d5ceb039f test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ │ check out git remote's default branch + │ │ args: jj git clone git-repo repo + │ ○ 5ed581429582 test-username@host.example.com 2001-02-03 04:05:15.000 +07:00 - 2001-02-03 04:05:15.000 +07:00 + ├─╯ point branch branch-1 to commit 3d9189bc56a1972729350456eb95ec5bf90be2a8 + │ args: jj branch set branch-1 -r branch-2@origin --at-op @- + ○ 817baaeefcbb test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ fetch from git remote into empty repo + │ args: jj git clone git-repo repo + ○ b51416386f26 test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ add workspace 'default' + ○ 9a7d829846af test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ initialize repo + ○ 000000000000 root() + "###); + let op_log_lines = stdout.lines().collect_vec(); + let op_id = op_log_lines[0].split(' ').nth(4).unwrap(); + let first_parent_id = op_log_lines[3].split(' ').nth(3).unwrap(); + let second_parent_id = op_log_lines[6].split(' ').nth(3).unwrap(); + + // Diff between the first parent of the merge operation and the merge operation. + let stdout = test_env.jj_cmd_success( + &repo_path, + &["op", "diff", "--from", first_parent_id, "--to", op_id], + ); + insta::assert_snapshot!(&stdout, @r###" + From operation 984d5ceb039f: check out git remote's default branch + To operation 6eeb006eccd0: resolve concurrent operations + + Changed local branches: + branch-1: + + (added) ulyvmwyz 1d843d1f branch-1?? branch-1@origin | Commit 1 + + (added) yuvsmzqk 3d9189bc branch-1?? branch-2@origin | Commit 2 + - ulyvmwyz 1d843d1f branch-1?? branch-1@origin | Commit 1 + "###); + + // Diff between the second parent of the merge operation and the merge + // operation. + let stdout = test_env.jj_cmd_success( + &repo_path, + &["op", "diff", "--from", second_parent_id, "--to", op_id], + ); + insta::assert_snapshot!(&stdout, @r###" + From operation 5ed581429582: point branch branch-1 to commit 3d9189bc56a1972729350456eb95ec5bf90be2a8 + To operation 6eeb006eccd0: resolve concurrent operations + + Changed commits: + ○ Change sqpuoqvxutmz + + sqpuoqvx 9708515f (empty) (no description set) + ○ Change qpvuntsmwlqt + - qpvuntsm hidden 230dd059 (empty) (no description set) + + Changed local branches: + branch-1: + + (added) ulyvmwyz 1d843d1f branch-1?? branch-1@origin | Commit 1 + + (added) yuvsmzqk 3d9189bc branch-1?? branch-2@origin | Commit 2 + - yuvsmzqk 3d9189bc branch-1?? branch-2@origin | Commit 2 + + Changed remote branches: + branch-1@origin: + + tracked ulyvmwyz 1d843d1f branch-1?? branch-1@origin | Commit 1 + - untracked ulyvmwyz 1d843d1f branch-1?? branch-1@origin | Commit 1 + "###); + + // Test fetching from git remote. + modify_git_repo(git_repo); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + branch: branch-1@origin [updated] tracked + branch: branch-2@origin [updated] untracked + branch: branch-3@origin [deleted] untracked + Abandoned 1 commits that are no longer reachable. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 6eeb006eccd0: resolve concurrent operations + To operation 9c57642e4a18: fetch from git remote(s) origin + + Changed commits: + ○ Change qzxslznxxpoz + + qzxslznx d487febd branch-2@origin | Commit 5 + ○ Change slvtnnzxztqy + + slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + ○ Change tqyxmsztkvot + - tqyxmszt hidden 3e785984 Commit 3 + + Changed local branches: + branch-1: + + (added) slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + + (added) yuvsmzqk 3d9189bc branch-1?? | Commit 2 + - (added) ulyvmwyz 1d843d1f Commit 1 + - (added) yuvsmzqk 3d9189bc branch-1?? | Commit 2 + + Changed remote branches: + branch-1@origin: + + tracked slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + - tracked ulyvmwyz 1d843d1f Commit 1 + branch-2@origin: + + untracked qzxslznx d487febd branch-2@origin | Commit 5 + - untracked yuvsmzqk 3d9189bc branch-1?? | Commit 2 + branch-3@origin: + + untracked (absent) + - untracked tqyxmszt hidden 3e785984 Commit 3 + "###); + + // Test creation of branch. + let (stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &["branch", "create", "branch-2", "-r", "branch-2@origin"], + ); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Created 1 branches pointing to qzxslznx d487febd branch-2 branch-2@origin | Commit 5 + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 9c57642e4a18: fetch from git remote(s) origin + To operation 8b280b4a5ea2: create branch branch-2 pointing to commit d487febd08e690ee775a4e0387e30d544307e409 + + Changed local branches: + branch-2: + + qzxslznx d487febd branch-2 branch-2@origin | Commit 5 + - (absent) + "###); + + // Test tracking of branch. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "track", "branch-2@origin"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Started tracking 1 remote branches. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 8b280b4a5ea2: create branch branch-2 pointing to commit d487febd08e690ee775a4e0387e30d544307e409 + To operation be38bc6501bc: track remote branch branch-2@origin + + Changed remote branches: + branch-2@origin: + + tracked qzxslznx d487febd branch-2 | Commit 5 + - untracked qzxslznx d487febd branch-2 | Commit 5 + "###); + + // Test creation of new commit. + let (stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["new", "branch-1@origin", "-m", "new commit"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Working copy now at: nmzmmopx bed2698f (empty) new commit + Parent commit : slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + Added 1 files, modified 0 files, removed 1 files + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + insta::assert_snapshot!(&stdout, @r###" + From operation be38bc6501bc: track remote branch branch-2@origin + To operation 8c9091fb718a: new empty commit + + Changed commits: + ○ Change nmzmmopxokps + + nmzmmopx bed2698f (empty) new commit + ○ Change sqpuoqvxutmz + - sqpuoqvx hidden 9708515f (empty) (no description set) + "###); + + // Test updating of local branch. + let (stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["branch", "set", "branch-1", "-r", "@"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Moved 1 branches to nmzmmopx bed2698f branch-1* | (empty) new commit + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 8c9091fb718a: new empty commit + To operation 6ff61c177324: point branch branch-1 to commit bed2698f6baf06f7eea56c616bc3fe36d9065651 + + Changed local branches: + branch-1: + + nmzmmopx bed2698f branch-1* | (empty) new commit + - (added) slvtnnzx 4f856199 branch-1@origin | Commit 4 + - (added) yuvsmzqk 3d9189bc Commit 2 + "###); + + // Test deletion of local branch. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "delete", "branch-2"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Deleted 1 branches. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 6ff61c177324: point branch branch-1 to commit bed2698f6baf06f7eea56c616bc3fe36d9065651 + To operation ecae5e879b40: delete branch branch-2 + + Changed local branches: + branch-2: + + (absent) + - qzxslznx d487febd branch-2@origin | Commit 5 + "###); + + // Test pushing to Git remote. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "push", "--tracked"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Branch changes to push to origin: + Move forward branch branch-1 from 4f856199edbf to bed2698f6baf + Delete branch branch-2 from d487febd08e6 + Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it. + Working copy now at: uuuvxpvw 2c8e84a8 (empty) (no description set) + Parent commit : nmzmmopx bed2698f branch-1 | (empty) new commit + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff"]); + insta::assert_snapshot!(&stdout, @r###" + From operation ecae5e879b40: delete branch branch-2 + To operation 96f11847b661: push all tracked branches to git remote origin + + Changed commits: + ○ Change uuuvxpvwspwr + + uuuvxpvw 2c8e84a8 (empty) (no description set) + + Changed remote branches: + branch-1@origin: + + tracked nmzmmopx bed2698f branch-1 | (empty) new commit + - tracked slvtnnzx 4f856199 Commit 4 + branch-2@origin: + + untracked (absent) + - tracked qzxslznx d487febd Commit 5 + "###); +} + +#[test] +fn test_op_diff_patch() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + // Update working copy with a single file and create new commit. + std::fs::write(repo_path.join("file"), "a\n").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["new"]); + insta::assert_snapshot!(&stdout, @""); + insta::assert_snapshot!(&stderr, @r###" + Working copy now at: rlvkpnrz 56950632 (empty) (no description set) + Parent commit : qpvuntsm 6b1027d2 (no description set) + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "--op", "@-", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + From operation b51416386f26: add workspace 'default' + To operation 6188e9d1f7da: snapshot working copy + + Changed commits: + ○ Change qpvuntsmwlqt + + qpvuntsm 6b1027d2 (no description set) + - qpvuntsm hidden 230dd059 (empty) (no description set) + diff --git a/file b/file + new file mode 100644 + index 0000000000..7898192261 + --- /dev/null + +++ b/file + @@ -1,0 +1,1 @@ + +a + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "--op", "@", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 6188e9d1f7da: snapshot working copy + To operation 8f6a879bef11: new empty commit + + Changed commits: + ○ Change rlvkpnrzqnoo + + rlvkpnrz 56950632 (empty) (no description set) + "###); + + // Squash the working copy commit. + std::fs::write(repo_path.join("file"), "b\n").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash"]); + insta::assert_snapshot!(&stdout, @""); + insta::assert_snapshot!(&stderr, @r###" + Working copy now at: mzvwutvl 9f4fb57f (empty) (no description set) + Parent commit : qpvuntsm 2ac85fd1 (no description set) + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + From operation 874d3a8b4c77: snapshot working copy + To operation c53f5f1afbc6: squash commits into 6b1027d2770cd0a39c468e525e52bf8c47e1464a + + Changed commits: + ○ Change mzvwutvlkqwt + │ + mzvwutvl 9f4fb57f (empty) (no description set) + │ ○ Change rlvkpnrzqnoo + ├─╯ - rlvkpnrz hidden 1d7f8f94 (no description set) + │ diff --git a/file b/file + │ index 7898192261..6178079822 100644 + │ --- a/file + │ +++ b/file + │ @@ -1,1 +1,1 @@ + │ -a + │ +b + ○ Change qpvuntsmwlqt + + qpvuntsm 2ac85fd1 (no description set) + - qpvuntsm hidden 6b1027d2 (no description set) + diff --git a/file b/file + index 7898192261..6178079822 100644 + --- a/file + +++ b/file + @@ -1,1 +1,1 @@ + -a + +b + "###); + + // Abandon the working copy commit. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["abandon"]); + insta::assert_snapshot!(&stdout, @""); + insta::assert_snapshot!(&stderr, @r###" + Abandoned commit mzvwutvl 9f4fb57f (empty) (no description set) + Working copy now at: yqosqzyt 33f321c4 (empty) (no description set) + Parent commit : qpvuntsm 2ac85fd1 (no description set) + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "diff", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + From operation c53f5f1afbc6: squash commits into 6b1027d2770cd0a39c468e525e52bf8c47e1464a + To operation e13dc1c7a3b3: abandon commit 9f4fb57fba25a7b47ce5980a5d9a4766778331e8 + + Changed commits: + ○ Change yqosqzytrlsw + + yqosqzyt 33f321c4 (empty) (no description set) + ○ Change mzvwutvlkqwt + - mzvwutvl hidden 9f4fb57f (empty) (no description set) + "###); +} + +#[test] +fn test_op_show() { + let test_env = TestEnvironment::default(); + let git_repo_path = test_env.env_root().join("git-repo"); + let git_repo = init_bare_git_repo(&git_repo_path); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "clone", "git-repo", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + // Overview of op log. + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "log"]); + insta::assert_snapshot!(&stdout, @r###" + @ 984d5ceb039f test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ check out git remote's default branch + │ args: jj git clone git-repo repo + ○ 817baaeefcbb test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ fetch from git remote into empty repo + │ args: jj git clone git-repo repo + ○ b51416386f26 test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ add workspace 'default' + ○ 9a7d829846af test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ initialize repo + ○ 000000000000 root() + "###); + + // The root operation is empty. + let stderr = test_env.jj_cmd_failure(&repo_path, &["op", "show", "0000000"]); + insta::assert_snapshot!(&stderr, @r###" + Error: Cannot show the root operation + "###); + + // Showing the latest operation. + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show", "@"]); + insta::assert_snapshot!(&stdout, @r###" + 984d5ceb039f test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + check out git remote's default branch + args: jj git clone git-repo repo + + Changed commits: + ○ Change sqpuoqvxutmz + + sqpuoqvx 9708515f (empty) (no description set) + ○ Change qpvuntsmwlqt + - qpvuntsm hidden 230dd059 (empty) (no description set) + + Changed local branches: + branch-1: + + ulyvmwyz 1d843d1f branch-1 | Commit 1 + - (absent) + + Changed remote branches: + branch-1@origin: + + tracked ulyvmwyz 1d843d1f branch-1 | Commit 1 + - untracked ulyvmwyz 1d843d1f branch-1 | Commit 1 + "###); + // `jj op show @` should behave identically to `jj op show`. + let stdout_without_op_id = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + assert_eq!(stdout, stdout_without_op_id); + + // Showing a given operation. + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show", "@-"]); + insta::assert_snapshot!(&stdout, @r###" + 817baaeefcbb test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + fetch from git remote into empty repo + args: jj git clone git-repo repo + + Changed commits: + ○ Change tqyxmsztkvot + + tqyxmszt 3e785984 branch-3@origin | Commit 3 + ○ Change yuvsmzqkmpws + + yuvsmzqk 3d9189bc branch-2@origin | Commit 2 + ○ Change ulyvmwyzwuwt + + ulyvmwyz 1d843d1f branch-1@origin | Commit 1 + + Changed remote branches: + branch-1@origin: + + untracked ulyvmwyz 1d843d1f branch-1@origin | Commit 1 + - untracked (absent) + branch-2@origin: + + untracked yuvsmzqk 3d9189bc branch-2@origin | Commit 2 + - untracked (absent) + branch-3@origin: + + untracked tqyxmszt 3e785984 branch-3@origin | Commit 3 + - untracked (absent) + "###); + + // Create a conflicted branch using a concurrent operation. + test_env.jj_cmd_ok( + &repo_path, + &[ + "branch", + "set", + "branch-1", + "-r", + "branch-2@origin", + "--at-op", + "@-", + ], + ); + let (_, stderr) = test_env.jj_cmd_ok(&repo_path, &["log"]); + insta::assert_snapshot!(&stderr, @r###" + Concurrent modification detected, resolving automatically. + "###); + // Showing a merge operation is empty. + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + 6c131cd79314 test-username@host.example.com 2001-02-03 04:05:14.000 +07:00 - 2001-02-03 04:05:14.000 +07:00 + resolve concurrent operations + args: jj log + "###); + + // Test fetching from git remote. + modify_git_repo(git_repo); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "fetch"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + branch: branch-1@origin [updated] tracked + branch: branch-2@origin [updated] untracked + branch: branch-3@origin [deleted] untracked + Abandoned 1 commits that are no longer reachable. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + 84466f397d80 test-username@host.example.com 2001-02-03 04:05:16.000 +07:00 - 2001-02-03 04:05:16.000 +07:00 + fetch from git remote(s) origin + args: jj git fetch + + Changed commits: + ○ Change qzxslznxxpoz + + qzxslznx d487febd branch-2@origin | Commit 5 + ○ Change slvtnnzxztqy + + slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + ○ Change tqyxmsztkvot + - tqyxmszt hidden 3e785984 Commit 3 + + Changed local branches: + branch-1: + + (added) slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + + (added) yuvsmzqk 3d9189bc branch-1?? | Commit 2 + - (added) ulyvmwyz 1d843d1f Commit 1 + - (added) yuvsmzqk 3d9189bc branch-1?? | Commit 2 + + Changed remote branches: + branch-1@origin: + + tracked slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + - tracked ulyvmwyz 1d843d1f Commit 1 + branch-2@origin: + + untracked qzxslznx d487febd branch-2@origin | Commit 5 + - untracked yuvsmzqk 3d9189bc branch-1?? | Commit 2 + branch-3@origin: + + untracked (absent) + - untracked tqyxmszt hidden 3e785984 Commit 3 + "###); + + // Test creation of branch. + let (stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &["branch", "create", "branch-2", "-r", "branch-2@origin"], + ); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Created 1 branches pointing to qzxslznx d487febd branch-2 branch-2@origin | Commit 5 + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + eea894b7c72f test-username@host.example.com 2001-02-03 04:05:18.000 +07:00 - 2001-02-03 04:05:18.000 +07:00 + create branch branch-2 pointing to commit d487febd08e690ee775a4e0387e30d544307e409 + args: jj branch create branch-2 -r branch-2@origin + + Changed local branches: + branch-2: + + qzxslznx d487febd branch-2 branch-2@origin | Commit 5 + - (absent) + "###); + + // Test tracking of branch. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "track", "branch-2@origin"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Started tracking 1 remote branches. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + d2d43732186a test-username@host.example.com 2001-02-03 04:05:20.000 +07:00 - 2001-02-03 04:05:20.000 +07:00 + track remote branch branch-2@origin + args: jj branch track branch-2@origin + + Changed remote branches: + branch-2@origin: + + tracked qzxslznx d487febd branch-2 | Commit 5 + - untracked qzxslznx d487febd branch-2 | Commit 5 + "###); + + // Test creation of new commit. + let (stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["new", "branch-1@origin", "-m", "new commit"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Working copy now at: nkmrtpmo 71fe694d (empty) new commit + Parent commit : slvtnnzx 4f856199 branch-1?? branch-1@origin | Commit 4 + Added 1 files, modified 0 files, removed 1 files + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + f85f06d144b6 test-username@host.example.com 2001-02-03 04:05:22.000 +07:00 - 2001-02-03 04:05:22.000 +07:00 + new empty commit + args: jj new branch-1@origin -m 'new commit' + + Changed commits: + ○ Change nkmrtpmomlro + + nkmrtpmo 71fe694d (empty) new commit + ○ Change sqpuoqvxutmz + - sqpuoqvx hidden 9708515f (empty) (no description set) + "###); + + // Test updating of local branch. + let (stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["branch", "set", "branch-1", "-r", "@"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Moved 1 branches to nkmrtpmo 71fe694d branch-1* | (empty) new commit + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + b55c8d9fdc63 test-username@host.example.com 2001-02-03 04:05:24.000 +07:00 - 2001-02-03 04:05:24.000 +07:00 + point branch branch-1 to commit 71fe694da7811a184f404fffe35cd62b0adb3d89 + args: jj branch set branch-1 -r @ + + Changed local branches: + branch-1: + + nkmrtpmo 71fe694d branch-1* | (empty) new commit + - (added) slvtnnzx 4f856199 branch-1@origin | Commit 4 + - (added) yuvsmzqk 3d9189bc Commit 2 + "###); + + // Test deletion of local branch. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["branch", "delete", "branch-2"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Deleted 1 branches. + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + 25dbc902dbf0 test-username@host.example.com 2001-02-03 04:05:26.000 +07:00 - 2001-02-03 04:05:26.000 +07:00 + delete branch branch-2 + args: jj branch delete branch-2 + + Changed local branches: + branch-2: + + (absent) + - qzxslznx d487febd branch-2@origin | Commit 5 + "###); + + // Test pushing to Git remote. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["git", "push", "--tracked"]); + insta::assert_snapshot!(&stdout, @r###" + "###); + insta::assert_snapshot!(&stderr, @r###" + Branch changes to push to origin: + Move forward branch branch-1 from 4f856199edbf to 71fe694da781 + Delete branch branch-2 from d487febd08e6 + Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it. + Working copy now at: wvuyspvk 6136f89a (empty) (no description set) + Parent commit : nkmrtpmo 71fe694d branch-1 | (empty) new commit + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show"]); + insta::assert_snapshot!(&stdout, @r###" + d8d2184e1621 test-username@host.example.com 2001-02-03 04:05:28.000 +07:00 - 2001-02-03 04:05:28.000 +07:00 + push all tracked branches to git remote origin + args: jj git push --tracked + + Changed commits: + ○ Change wvuyspvkupzz + + wvuyspvk 6136f89a (empty) (no description set) + + Changed remote branches: + branch-1@origin: + + tracked nkmrtpmo 71fe694d branch-1 | (empty) new commit + - tracked slvtnnzx 4f856199 Commit 4 + branch-2@origin: + + untracked (absent) + - tracked qzxslznx d487febd Commit 5 + "###); +} + +#[test] +fn test_op_show_patch() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + // Update working copy with a single file and create new commit. + std::fs::write(repo_path.join("file"), "a\n").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["new"]); + insta::assert_snapshot!(&stdout, @""); + insta::assert_snapshot!(&stderr, @r###" + Working copy now at: rlvkpnrz 56950632 (empty) (no description set) + Parent commit : qpvuntsm 6b1027d2 (no description set) + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show", "@-", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + 6188e9d1f7da test-username@host.example.com 2001-02-03 04:05:08.000 +07:00 - 2001-02-03 04:05:08.000 +07:00 + snapshot working copy + args: jj new + + Changed commits: + ○ Change qpvuntsmwlqt + + qpvuntsm 6b1027d2 (no description set) + - qpvuntsm hidden 230dd059 (empty) (no description set) + diff --git a/file b/file + new file mode 100644 + index 0000000000..7898192261 + --- /dev/null + +++ b/file + @@ -1,0 +1,1 @@ + +a + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show", "@", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + 8f6a879bef11 test-username@host.example.com 2001-02-03 04:05:08.000 +07:00 - 2001-02-03 04:05:08.000 +07:00 + new empty commit + args: jj new + + Changed commits: + ○ Change rlvkpnrzqnoo + + rlvkpnrz 56950632 (empty) (no description set) + "###); + + // Squash the working copy commit. + std::fs::write(repo_path.join("file"), "b\n").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash"]); + insta::assert_snapshot!(&stdout, @""); + insta::assert_snapshot!(&stderr, @r###" + Working copy now at: mzvwutvl 9f4fb57f (empty) (no description set) + Parent commit : qpvuntsm 2ac85fd1 (no description set) + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + c53f5f1afbc6 test-username@host.example.com 2001-02-03 04:05:11.000 +07:00 - 2001-02-03 04:05:11.000 +07:00 + squash commits into 6b1027d2770cd0a39c468e525e52bf8c47e1464a + args: jj squash + + Changed commits: + ○ Change mzvwutvlkqwt + │ + mzvwutvl 9f4fb57f (empty) (no description set) + │ ○ Change rlvkpnrzqnoo + ├─╯ - rlvkpnrz hidden 1d7f8f94 (no description set) + │ diff --git a/file b/file + │ index 7898192261..6178079822 100644 + │ --- a/file + │ +++ b/file + │ @@ -1,1 +1,1 @@ + │ -a + │ +b + ○ Change qpvuntsmwlqt + + qpvuntsm 2ac85fd1 (no description set) + - qpvuntsm hidden 6b1027d2 (no description set) + diff --git a/file b/file + index 7898192261..6178079822 100644 + --- a/file + +++ b/file + @@ -1,1 +1,1 @@ + -a + +b + "###); + + // Abandon the working copy commit. + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["abandon"]); + insta::assert_snapshot!(&stdout, @""); + insta::assert_snapshot!(&stderr, @r###" + Abandoned commit mzvwutvl 9f4fb57f (empty) (no description set) + Working copy now at: yqosqzyt 33f321c4 (empty) (no description set) + Parent commit : qpvuntsm 2ac85fd1 (no description set) + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "show", "-p", "--git"]); + insta::assert_snapshot!(&stdout, @r###" + e13dc1c7a3b3 test-username@host.example.com 2001-02-03 04:05:13.000 +07:00 - 2001-02-03 04:05:13.000 +07:00 + abandon commit 9f4fb57fba25a7b47ce5980a5d9a4766778331e8 + args: jj abandon + + Changed commits: + ○ Change yqosqzytrlsw + + yqosqzyt 33f321c4 (empty) (no description set) + ○ Change mzvwutvlkqwt + - mzvwutvl hidden 9f4fb57f (empty) (no description set) + "###); +} + +fn init_bare_git_repo(git_repo_path: &Path) -> git2::Repository { + let git_repo = git2::Repository::init_bare(git_repo_path).unwrap(); + let git_blob_oid = git_repo.blob(b"some content").unwrap(); + let mut git_tree_builder = git_repo.treebuilder(None).unwrap(); + git_tree_builder + .insert("some-file", git_blob_oid, 0o100644) + .unwrap(); + let git_tree_id = git_tree_builder.write().unwrap(); + drop(git_tree_builder); + let git_tree = git_repo.find_tree(git_tree_id).unwrap(); + let git_signature = git2::Signature::new( + "Git User", + "git.user@example.com", + &git2::Time::new(123, 60), + ) + .unwrap(); + git_repo + .commit( + Some("refs/heads/branch-1"), + &git_signature, + &git_signature, + "Commit 1", + &git_tree, + &[], + ) + .unwrap(); + git_repo + .commit( + Some("refs/heads/branch-2"), + &git_signature, + &git_signature, + "Commit 2", + &git_tree, + &[], + ) + .unwrap(); + git_repo + .commit( + Some("refs/heads/branch-3"), + &git_signature, + &git_signature, + "Commit 3", + &git_tree, + &[], + ) + .unwrap(); + drop(git_tree); + git_repo.set_head("refs/heads/branch-1").unwrap(); + git_repo +} + +fn modify_git_repo(git_repo: git2::Repository) -> git2::Repository { + let git_blob_oid = git_repo.blob(b"more content").unwrap(); + let mut git_tree_builder = git_repo.treebuilder(None).unwrap(); + git_tree_builder + .insert("next-file", git_blob_oid, 0o100644) + .unwrap(); + let git_tree_id = git_tree_builder.write().unwrap(); + drop(git_tree_builder); + let git_tree = git_repo.find_tree(git_tree_id).unwrap(); + let git_signature = git2::Signature::new( + "Git User", + "git.user@example.com", + &git2::Time::new(123, 60), + ) + .unwrap(); + let branch1_commit = git_repo + .find_reference("refs/heads/branch-1") + .unwrap() + .peel_to_commit() + .unwrap(); + let branch2_commit = git_repo + .find_reference("refs/heads/branch-2") + .unwrap() + .peel_to_commit() + .unwrap(); + git_repo + .commit( + Some("refs/heads/branch-1"), + &git_signature, + &git_signature, + "Commit 4", + &git_tree, + &[&branch1_commit], + ) + .unwrap(); + git_repo + .commit( + Some("refs/heads/branch-2"), + &git_signature, + &git_signature, + "Commit 5", + &git_tree, + &[&branch2_commit], + ) + .unwrap(); + git_repo + .find_reference("refs/heads/branch-3") + .unwrap() + .delete() + .unwrap(); + drop(git_tree); + drop(branch1_commit); + drop(branch2_commit); + git_repo +} + fn get_log_output(test_env: &TestEnvironment, repo_path: &Path, op_id: &str) -> String { test_env.jj_cmd_success( repo_path, diff --git a/lib/src/repo.rs b/lib/src/repo.rs index dc8f28e404..fdb2a7cddb 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -700,21 +700,47 @@ impl RepoLoader { Arc::new(repo) } + /// Merges the given `operations` into a single operation. + /// Assumes that there is at least one operation. + pub fn merge_operations( + &self, + settings: &UserSettings, + operations: Vec, + tx_description: Option<&str>, + ) -> Result { + let num_operations = operations.len(); + let mut operations = operations.into_iter(); + let base_op = operations.next().unwrap(); + let final_op = if num_operations > 1 { + let base_repo = self.load_at(&base_op)?; + let mut tx = base_repo.start_transaction(settings); + for other_op in operations { + tx.merge_operation(other_op)?; + tx.mut_repo().rebase_descendants(settings)?; + } + let tx_description = tx_description.map_or_else( + || format!("merge {} operations", num_operations), + |tx_description| tx_description.to_string(), + ); + let merged_repo = tx.write(tx_description).leave_unpublished(); + merged_repo.operation().clone() + } else { + base_op + }; + + Ok(final_op) + } + fn _resolve_op_heads( &self, op_heads: Vec, user_settings: &UserSettings, ) -> Result { - let base_repo = self.load_at(&op_heads[0])?; - let mut tx = base_repo.start_transaction(user_settings); - for other_op_head in op_heads.into_iter().skip(1) { - tx.merge_operation(other_op_head)?; - tx.mut_repo().rebase_descendants(user_settings)?; - } - let merged_repo = tx - .write("resolve concurrent operations") - .leave_unpublished(); - Ok(merged_repo.operation().clone()) + self.merge_operations( + user_settings, + op_heads, + Some("resolve concurrent operations"), + ) } fn _finish_load(&self, operation: Operation, view: View) -> Arc { @@ -1564,6 +1590,10 @@ impl MutableRepo { self.view.mark_dirty(); } + pub fn merge_index(&mut self, other_repo: &ReadonlyRepo) { + self.index.merge_in(other_repo.readonly_index()); + } + fn merge_view(&mut self, base: &View, other: &View) { // Merge working-copy commits. If there's a conflict, we keep the self side. for (workspace_id, base_wc_commit) in base.wc_commit_ids() {