just playing with tangled
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}