just playing with tangled
at gvimdiff 611 lines 23 kB view raw
1// Copyright 2024 The Jujutsu Authors 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// https://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15use std::collections::HashMap; 16use std::convert::Infallible; 17use std::sync::Arc; 18 19use clap_complete::ArgValueCandidates; 20use indexmap::IndexMap; 21use itertools::Itertools as _; 22use jj_lib::backend::ChangeId; 23use jj_lib::backend::CommitId; 24use jj_lib::commit::Commit; 25use jj_lib::dag_walk; 26use jj_lib::graph::GraphEdge; 27use jj_lib::graph::TopoGroupedGraphIterator; 28use jj_lib::matchers::EverythingMatcher; 29use jj_lib::op_store::RefTarget; 30use jj_lib::op_store::RemoteRef; 31use jj_lib::op_store::RemoteRefState; 32use jj_lib::refs::diff_named_commit_ids; 33use jj_lib::refs::diff_named_ref_targets; 34use jj_lib::refs::diff_named_remote_refs; 35use jj_lib::repo::ReadonlyRepo; 36use jj_lib::repo::Repo; 37use jj_lib::revset; 38use jj_lib::revset::RevsetIteratorExt as _; 39 40use crate::cli_util::CommandHelper; 41use crate::cli_util::LogContentFormat; 42use crate::command_error::CommandError; 43use crate::commit_templater::CommitTemplateLanguage; 44use crate::complete; 45use crate::diff_util::diff_formats_for_log; 46use crate::diff_util::DiffFormatArgs; 47use crate::diff_util::DiffRenderer; 48use crate::formatter::Formatter; 49use crate::graphlog::get_graphlog; 50use crate::graphlog::GraphStyle; 51use crate::templater::TemplateRenderer; 52use crate::ui::Ui; 53 54/// Compare changes to the repository between two operations 55#[derive(clap::Args, Clone, Debug)] 56pub struct OperationDiffArgs { 57 /// Show repository changes in this operation, compared to its parent 58 #[arg( 59 long, 60 visible_alias = "op", 61 add = ArgValueCandidates::new(complete::operations), 62 )] 63 operation: Option<String>, 64 /// Show repository changes from this operation 65 #[arg( 66 long, short, 67 conflicts_with = "operation", 68 add = ArgValueCandidates::new(complete::operations), 69 )] 70 from: Option<String>, 71 /// Show repository changes to this operation 72 #[arg( 73 long, short, 74 conflicts_with = "operation", 75 add = ArgValueCandidates::new(complete::operations), 76 )] 77 to: Option<String>, 78 /// Don't show the graph, show a flat list of modified changes 79 #[arg(long)] 80 no_graph: bool, 81 /// Show patch of modifications to changes 82 /// 83 /// If the previous version has different parents, it will be temporarily 84 /// rebased to the parents of the new version, so the diff is not 85 /// contaminated by unrelated changes. 86 #[arg(long, short = 'p')] 87 patch: bool, 88 #[command(flatten)] 89 diff_format: DiffFormatArgs, 90} 91 92pub fn cmd_op_diff( 93 ui: &mut Ui, 94 command: &CommandHelper, 95 args: &OperationDiffArgs, 96) -> Result<(), CommandError> { 97 let workspace_command = command.workspace_helper(ui)?; 98 let workspace_env = workspace_command.env(); 99 let repo_loader = workspace_command.workspace().repo_loader(); 100 let settings = workspace_command.settings(); 101 let from_op; 102 let to_op; 103 if args.from.is_some() || args.to.is_some() { 104 from_op = workspace_command.resolve_single_op(args.from.as_deref().unwrap_or("@"))?; 105 to_op = workspace_command.resolve_single_op(args.to.as_deref().unwrap_or("@"))?; 106 } else { 107 to_op = workspace_command.resolve_single_op(args.operation.as_deref().unwrap_or("@"))?; 108 let to_op_parents: Vec<_> = to_op.parents().try_collect()?; 109 from_op = repo_loader.merge_operations(to_op_parents, None)?; 110 } 111 let graph_style = GraphStyle::from_settings(settings)?; 112 let with_content_format = LogContentFormat::new(ui, settings)?; 113 114 let from_repo = repo_loader.load_at(&from_op)?; 115 let to_repo = repo_loader.load_at(&to_op)?; 116 117 // Create a new transaction starting from `to_repo`. 118 let mut tx = to_repo.start_transaction(); 119 // Merge index from `from_repo` to `to_repo`, so commits in `from_repo` are 120 // accessible. 121 tx.repo_mut().merge_index(&from_repo); 122 let merged_repo = tx.repo(); 123 124 let diff_renderer = { 125 let formats = diff_formats_for_log(settings, &args.diff_format, args.patch)?; 126 let path_converter = workspace_env.path_converter(); 127 let conflict_marker_style = workspace_env.conflict_marker_style(); 128 (!formats.is_empty()) 129 .then(|| DiffRenderer::new(merged_repo, path_converter, conflict_marker_style, formats)) 130 }; 131 let id_prefix_context = workspace_env.new_id_prefix_context(); 132 let commit_summary_template = { 133 let language = workspace_env.commit_template_language(merged_repo, &id_prefix_context); 134 let text = settings.get_string("templates.commit_summary")?; 135 workspace_env.parse_template(ui, &language, &text, CommitTemplateLanguage::wrap_commit)? 136 }; 137 138 let op_summary_template = workspace_command.operation_summary_template(); 139 ui.request_pager(); 140 let mut formatter = ui.stdout_formatter(); 141 write!(formatter, "From operation: ")?; 142 op_summary_template.format(&from_op, &mut *formatter)?; 143 writeln!(formatter)?; 144 write!(formatter, " To operation: ")?; 145 op_summary_template.format(&to_op, &mut *formatter)?; 146 writeln!(formatter)?; 147 148 show_op_diff( 149 ui, 150 formatter.as_mut(), 151 merged_repo, 152 &from_repo, 153 &to_repo, 154 &commit_summary_template, 155 (!args.no_graph).then_some(graph_style), 156 &with_content_format, 157 diff_renderer.as_ref(), 158 ) 159} 160 161/// Computes and shows the differences between two operations, using the given 162/// `ReadonlyRepo`s for the operations. 163/// `current_repo` should contain a `Repo` with the indices of both repos merged 164/// into it. 165#[expect(clippy::too_many_arguments)] 166pub fn show_op_diff( 167 ui: &Ui, 168 formatter: &mut dyn Formatter, 169 current_repo: &dyn Repo, 170 from_repo: &Arc<ReadonlyRepo>, 171 to_repo: &Arc<ReadonlyRepo>, 172 commit_summary_template: &TemplateRenderer<Commit>, 173 graph_style: Option<GraphStyle>, 174 with_content_format: &LogContentFormat, 175 diff_renderer: Option<&DiffRenderer>, 176) -> Result<(), CommandError> { 177 let changes = compute_operation_commits_diff(current_repo, from_repo, to_repo)?; 178 179 let commit_id_change_id_map: HashMap<CommitId, ChangeId> = changes 180 .iter() 181 .flat_map(|(change_id, modified_change)| { 182 itertools::chain( 183 &modified_change.added_commits, 184 &modified_change.removed_commits, 185 ) 186 .map(|commit| (commit.id().clone(), change_id.clone())) 187 }) 188 .collect(); 189 190 let change_parents: HashMap<_, _> = changes 191 .iter() 192 .map(|(change_id, modified_change)| { 193 let parent_change_ids = get_parent_changes(modified_change, &commit_id_change_id_map); 194 (change_id.clone(), parent_change_ids) 195 }) 196 .collect(); 197 198 // Order changes in reverse topological order. 199 let ordered_change_ids = dag_walk::topo_order_reverse( 200 changes.keys().cloned().collect_vec(), 201 |change_id: &ChangeId| change_id.clone(), 202 |change_id: &ChangeId| change_parents.get(change_id).unwrap().clone(), 203 ); 204 205 if !ordered_change_ids.is_empty() { 206 writeln!(formatter)?; 207 with_content_format.write(formatter, |formatter| { 208 writeln!(formatter, "Changed commits:") 209 })?; 210 if let Some(graph_style) = graph_style { 211 let mut raw_output = formatter.raw()?; 212 let mut graph = get_graphlog(graph_style, raw_output.as_mut()); 213 214 let graph_iter = TopoGroupedGraphIterator::new(ordered_change_ids.iter().map( 215 |change_id| -> Result<_, Infallible> { 216 let parent_change_ids = change_parents.get(change_id).unwrap(); 217 Ok(( 218 change_id.clone(), 219 parent_change_ids 220 .iter() 221 .map(|parent_change_id| GraphEdge::direct(parent_change_id.clone())) 222 .collect_vec(), 223 )) 224 }, 225 )); 226 227 for node in graph_iter { 228 let (change_id, edges) = node.unwrap(); 229 let modified_change = changes.get(&change_id).unwrap(); 230 231 let mut buffer = vec![]; 232 let within_graph = with_content_format.sub_width(graph.width(&change_id, &edges)); 233 within_graph.write(ui.new_formatter(&mut buffer).as_mut(), |formatter| { 234 write_modified_change_summary( 235 formatter, 236 commit_summary_template, 237 modified_change, 238 ) 239 })?; 240 if !buffer.ends_with(b"\n") { 241 buffer.push(b'\n'); 242 } 243 if let Some(diff_renderer) = &diff_renderer { 244 let mut formatter = ui.new_formatter(&mut buffer); 245 show_change_diff( 246 ui, 247 formatter.as_mut(), 248 diff_renderer, 249 modified_change, 250 within_graph.width(), 251 )?; 252 } 253 254 // TODO: customize node symbol? 255 let node_symbol = ""; 256 graph.add_node( 257 &change_id, 258 &edges, 259 node_symbol, 260 &String::from_utf8_lossy(&buffer), 261 )?; 262 } 263 } else { 264 for change_id in ordered_change_ids { 265 let modified_change = changes.get(&change_id).unwrap(); 266 with_content_format.write(formatter, |formatter| { 267 write_modified_change_summary( 268 formatter, 269 commit_summary_template, 270 modified_change, 271 ) 272 })?; 273 if let Some(diff_renderer) = &diff_renderer { 274 let width = with_content_format.width(); 275 show_change_diff(ui, formatter, diff_renderer, modified_change, width)?; 276 } 277 } 278 } 279 } 280 281 let changed_working_copies = diff_named_commit_ids( 282 from_repo.view().wc_commit_ids(), 283 to_repo.view().wc_commit_ids(), 284 ) 285 .collect_vec(); 286 if !changed_working_copies.is_empty() { 287 writeln!(formatter)?; 288 for (name, (from_commit, to_commit)) in changed_working_copies { 289 with_content_format.write(formatter, |formatter| { 290 // Usually, there is at most one working copy changed per operation, so we put 291 // the working copy name in the heading. 292 write!(formatter, "Changed working copy ")?; 293 write!(formatter.labeled("working_copies"), "{}@", name.as_symbol())?; 294 writeln!(formatter, ":")?; 295 write_ref_target_summary( 296 formatter, 297 current_repo, 298 commit_summary_template, 299 &RefTarget::resolved(to_commit.cloned()), 300 true, 301 None, 302 )?; 303 write_ref_target_summary( 304 formatter, 305 current_repo, 306 commit_summary_template, 307 &RefTarget::resolved(from_commit.cloned()), 308 false, 309 None, 310 ) 311 })?; 312 } 313 } 314 315 let changed_local_bookmarks = diff_named_ref_targets( 316 from_repo.view().local_bookmarks(), 317 to_repo.view().local_bookmarks(), 318 ) 319 .collect_vec(); 320 if !changed_local_bookmarks.is_empty() { 321 writeln!(formatter)?; 322 with_content_format.write(formatter, |formatter| { 323 writeln!(formatter, "Changed local bookmarks:") 324 })?; 325 for (name, (from_target, to_target)) in changed_local_bookmarks { 326 with_content_format.write(formatter, |formatter| { 327 writeln!(formatter, "{name}:", name = name.as_symbol())?; 328 write_ref_target_summary( 329 formatter, 330 current_repo, 331 commit_summary_template, 332 to_target, 333 true, 334 None, 335 )?; 336 write_ref_target_summary( 337 formatter, 338 current_repo, 339 commit_summary_template, 340 from_target, 341 false, 342 None, 343 ) 344 })?; 345 } 346 } 347 348 let changed_tags = 349 diff_named_ref_targets(from_repo.view().tags(), to_repo.view().tags()).collect_vec(); 350 if !changed_tags.is_empty() { 351 writeln!(formatter)?; 352 with_content_format.write(formatter, |formatter| writeln!(formatter, "Changed tags:"))?; 353 for (name, (from_target, to_target)) in changed_tags { 354 with_content_format.write(formatter, |formatter| { 355 writeln!(formatter, "{name}:", name = name.as_symbol())?; 356 write_ref_target_summary( 357 formatter, 358 current_repo, 359 commit_summary_template, 360 to_target, 361 true, 362 None, 363 )?; 364 write_ref_target_summary( 365 formatter, 366 current_repo, 367 commit_summary_template, 368 from_target, 369 false, 370 None, 371 ) 372 })?; 373 } 374 writeln!(formatter)?; 375 } 376 377 let changed_remote_bookmarks = diff_named_remote_refs( 378 from_repo.view().all_remote_bookmarks(), 379 to_repo.view().all_remote_bookmarks(), 380 ) 381 // Skip updates to the local git repo, since they should typically be covered in 382 // local branches. 383 .filter(|(symbol, _)| !jj_lib::git::is_special_git_remote(symbol.remote)) 384 .collect_vec(); 385 if !changed_remote_bookmarks.is_empty() { 386 writeln!(formatter)?; 387 with_content_format.write(formatter, |formatter| { 388 writeln!(formatter, "Changed remote bookmarks:") 389 })?; 390 let get_remote_ref_prefix = |remote_ref: &RemoteRef| match remote_ref.state { 391 RemoteRefState::New => "untracked", 392 RemoteRefState::Tracked => "tracked", 393 }; 394 for (symbol, (from_ref, to_ref)) in changed_remote_bookmarks { 395 with_content_format.write(formatter, |formatter| { 396 writeln!(formatter, "{symbol}:")?; 397 write_ref_target_summary( 398 formatter, 399 current_repo, 400 commit_summary_template, 401 &to_ref.target, 402 true, 403 Some(get_remote_ref_prefix(to_ref)), 404 )?; 405 write_ref_target_summary( 406 formatter, 407 current_repo, 408 commit_summary_template, 409 &from_ref.target, 410 false, 411 Some(get_remote_ref_prefix(from_ref)), 412 ) 413 })?; 414 } 415 } 416 417 Ok(()) 418} 419 420/// Writes a summary for the given `ModifiedChange`. 421fn write_modified_change_summary( 422 formatter: &mut dyn Formatter, 423 commit_summary_template: &TemplateRenderer<Commit>, 424 modified_change: &ModifiedChange, 425) -> Result<(), std::io::Error> { 426 for commit in &modified_change.added_commits { 427 formatter.with_label("diff", |formatter| write!(formatter.labeled("added"), "+"))?; 428 write!(formatter, " ")?; 429 commit_summary_template.format(commit, formatter)?; 430 writeln!(formatter)?; 431 } 432 for commit in &modified_change.removed_commits { 433 formatter.with_label("diff", |formatter| { 434 write!(formatter.labeled("removed"), "-") 435 })?; 436 write!(formatter, " ")?; 437 commit_summary_template.format(commit, formatter)?; 438 writeln!(formatter)?; 439 } 440 Ok(()) 441} 442 443/// Writes a summary for the given `RefTarget`. 444fn write_ref_target_summary( 445 formatter: &mut dyn Formatter, 446 repo: &dyn Repo, 447 commit_summary_template: &TemplateRenderer<Commit>, 448 ref_target: &RefTarget, 449 added: bool, 450 prefix: Option<&str>, 451) -> Result<(), CommandError> { 452 let write_prefix = |formatter: &mut dyn Formatter, 453 added: bool, 454 prefix: Option<&str>| 455 -> Result<(), CommandError> { 456 formatter.with_label("diff", |formatter| { 457 write!( 458 formatter.labeled(if added { "added" } else { "removed" }), 459 "{}", 460 if added { "+" } else { "-" } 461 ) 462 })?; 463 write!(formatter, " ")?; 464 if let Some(prefix) = prefix { 465 write!(formatter, "{prefix} ")?; 466 } 467 Ok(()) 468 }; 469 if ref_target.is_absent() { 470 write_prefix(formatter, added, prefix)?; 471 writeln!(formatter, "(absent)")?; 472 } else if ref_target.has_conflict() { 473 for commit_id in ref_target.added_ids() { 474 write_prefix(formatter, added, prefix)?; 475 write!(formatter, "(added) ")?; 476 let commit = repo.store().get_commit(commit_id)?; 477 commit_summary_template.format(&commit, formatter)?; 478 writeln!(formatter)?; 479 } 480 for commit_id in ref_target.removed_ids() { 481 write_prefix(formatter, added, prefix)?; 482 write!(formatter, "(removed) ")?; 483 let commit = repo.store().get_commit(commit_id)?; 484 commit_summary_template.format(&commit, formatter)?; 485 writeln!(formatter)?; 486 } 487 } else { 488 write_prefix(formatter, added, prefix)?; 489 let commit_id = ref_target.as_normal().unwrap(); 490 let commit = repo.store().get_commit(commit_id)?; 491 commit_summary_template.format(&commit, formatter)?; 492 writeln!(formatter)?; 493 } 494 Ok(()) 495} 496 497/// Returns the change IDs of the parents of the given `modified_change`, which 498/// are the parents of all newly added commits for the change, or the parents of 499/// all removed commits if there are no added commits. 500fn get_parent_changes( 501 modified_change: &ModifiedChange, 502 commit_id_change_id_map: &HashMap<CommitId, ChangeId>, 503) -> Vec<ChangeId> { 504 // TODO: how should we handle multiple added or removed commits? 505 if !modified_change.added_commits.is_empty() { 506 modified_change 507 .added_commits 508 .iter() 509 .flat_map(|commit| commit.parent_ids()) 510 .filter_map(|parent_id| commit_id_change_id_map.get(parent_id).cloned()) 511 .unique() 512 .collect_vec() 513 } else { 514 modified_change 515 .removed_commits 516 .iter() 517 .flat_map(|commit| commit.parent_ids()) 518 .filter_map(|parent_id| commit_id_change_id_map.get(parent_id).cloned()) 519 .unique() 520 .collect_vec() 521 } 522} 523 524#[derive(Clone, Debug, PartialEq, Eq)] 525struct ModifiedChange { 526 added_commits: Vec<Commit>, 527 removed_commits: Vec<Commit>, 528} 529 530/// Compute the changes in commits between two operations, returned as a 531/// `HashMap` from `ChangeId` to a `ModifiedChange` struct containing the added 532/// and removed commits for the change ID. 533fn compute_operation_commits_diff( 534 repo: &dyn Repo, 535 from_repo: &ReadonlyRepo, 536 to_repo: &ReadonlyRepo, 537) -> Result<IndexMap<ChangeId, ModifiedChange>, CommandError> { 538 let mut changes: IndexMap<ChangeId, ModifiedChange> = IndexMap::new(); 539 540 let from_heads = from_repo.view().heads().iter().cloned().collect_vec(); 541 let to_heads = to_repo.view().heads().iter().cloned().collect_vec(); 542 543 // Find newly added commits in `to_repo` which were not present in 544 // `from_repo`. 545 for commit in revset::walk_revs(repo, &to_heads, &from_heads)? 546 .iter() 547 .commits(repo.store()) 548 { 549 let commit = commit?; 550 let modified_change = changes 551 .entry(commit.change_id().clone()) 552 .or_insert_with(|| ModifiedChange { 553 added_commits: vec![], 554 removed_commits: vec![], 555 }); 556 modified_change.added_commits.push(commit); 557 } 558 559 // Find commits which were hidden in `to_repo`. 560 for commit in revset::walk_revs(repo, &from_heads, &to_heads)? 561 .iter() 562 .commits(repo.store()) 563 { 564 let commit = commit?; 565 let modified_change = changes 566 .entry(commit.change_id().clone()) 567 .or_insert_with(|| ModifiedChange { 568 added_commits: vec![], 569 removed_commits: vec![], 570 }); 571 modified_change.removed_commits.push(commit); 572 } 573 574 Ok(changes) 575} 576 577/// Displays the diffs of a modified change. The output differs based on the 578/// commits added and removed for the change. 579/// If there is a single added and removed commit, the diff is shown between the 580/// removed commit and the added commit rebased onto the removed commit's 581/// parents. If there is only a single added or single removed commit, the diff 582/// is shown of that commit's contents. 583fn show_change_diff( 584 ui: &Ui, 585 formatter: &mut dyn Formatter, 586 diff_renderer: &DiffRenderer, 587 change: &ModifiedChange, 588 width: usize, 589) -> Result<(), CommandError> { 590 match (&*change.removed_commits, &*change.added_commits) { 591 (predecessors @ ([] | [_]), [commit]) => { 592 // New or modified change. If the modification involved a rebase, 593 // show diffs from the rebased tree. 594 diff_renderer.show_inter_diff( 595 ui, 596 formatter, 597 predecessors, 598 commit, 599 &EverythingMatcher, 600 width, 601 )?; 602 } 603 ([commit], []) => { 604 // TODO: Should we show a reverse diff? 605 diff_renderer.show_patch(ui, formatter, commit, &EverythingMatcher, width)?; 606 } 607 ([_, _, ..], _) | (_, [_, _, ..]) => {} 608 ([], []) => panic!("ModifiedChange should have at least one entry"), 609 } 610 Ok(()) 611}