From ce3436b92beeb1515bf1b9bf120ff1cf5f023e51 Mon Sep 17 00:00:00 2001 From: Yuya Nishihara Date: Wed, 30 Oct 2024 21:21:44 +0900 Subject: [PATCH] cli: add "absorb" machinery and command The destination commits are selected based on annotation, which I think is basically the same as "hg absorb" (except for handling of consecutive hunks.) However, we don't compute a full interleaved delta right now, and the hunks are merged in the same way as "jj squash". This means absorbed hunks might produce conflicts if no context lines exist. Still I think this is more intuitive than selecting destination commits based on patch commutativity. I've left inline comments to the tests where behavior is different from "hg absorb", but these aren't exhaustively checked. Closes #170 --- CHANGELOG.md | 2 + cli/src/commands/absorb.rs | 1172 +++++++++++++++++++++++++++ cli/src/commands/mod.rs | 3 + cli/tests/cli-reference@.md.snap | 29 + cli/tests/runner.rs | 1 + cli/tests/test_absorb_command.rs | 636 +++++++++++++++ cli/tests/test_immutable_commits.rs | 18 +- 7 files changed, 1856 insertions(+), 5 deletions(-) create mode 100644 cli/src/commands/absorb.rs create mode 100644 cli/tests/test_absorb_command.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0ab7fdd7..a2d821e76a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * The `jj desc` and `jj st` aliases are now hidden to not interfere with shell completion. They remain available. +* New command `jj absorb` that moves changes to stack of mutable revisions. + * New command `jj util exec` that can be used for arbitrary aliases. * A preview of improved shell completions was added. Please refer to the diff --git a/cli/src/commands/absorb.rs b/cli/src/commands/absorb.rs new file mode 100644 index 0000000000..97446d24b9 --- /dev/null +++ b/cli/src/commands/absorb.rs @@ -0,0 +1,1172 @@ +// 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::cmp; +use std::collections::HashMap; +use std::io::Read; +use std::ops::Range; +use std::rc::Rc; + +use bstr::BString; +use futures::StreamExt as _; +use itertools::Itertools as _; +use jj_lib::annotate::get_annotation_with_file_content; +use jj_lib::backend::BackendError; +use jj_lib::backend::BackendResult; +use jj_lib::backend::CommitId; +use jj_lib::backend::FileId; +use jj_lib::backend::TreeValue; +use jj_lib::commit::Commit; +use jj_lib::conflicts::materialized_diff_stream; +use jj_lib::conflicts::MaterializedTreeValue; +use jj_lib::copies::CopyRecords; +use jj_lib::diff::Diff; +use jj_lib::diff::DiffHunkKind; +use jj_lib::matchers::Matcher; +use jj_lib::merge::Merge; +use jj_lib::merged_tree::MergedTree; +use jj_lib::merged_tree::MergedTreeBuilder; +use jj_lib::repo::MutableRepo; +use jj_lib::repo::Repo; +use jj_lib::repo_path::RepoPath; +use jj_lib::repo_path::RepoPathUiConverter; +use jj_lib::revset::ResolvedRevsetExpression; +use jj_lib::settings::UserSettings; +use pollster::FutureExt as _; +use tracing::instrument; + +use crate::cli_util::CommandHelper; +use crate::cli_util::RevisionArg; +use crate::command_error::CommandError; +use crate::ui::Ui; + +/// Move changes from a revision into the stack of mutable revisions +/// +/// This command splits changes in the source revision and moves each change to +/// the closest mutable ancestor where the corresponding lines were modified +/// last. If the destination revision cannot be determined unambiguously, the +/// change will be left in the source revision. +/// +/// The modification made by `jj absorb` can be reviewed by `jj op show -p`. +#[derive(clap::Args, Clone, Debug)] +pub(crate) struct AbsorbArgs { + /// Source revision to absorb from + #[arg(long, short, default_value = "@")] + from: RevisionArg, + /// Destination revisions to absorb into + /// + /// Only ancestors of the source revision will be considered. + #[arg(long, short = 't', visible_alias = "to", default_value = "mutable()")] + into: Vec, + /// Move only changes to these paths (instead of all paths) + #[arg(value_hint = clap::ValueHint::AnyPath)] + paths: Vec, +} + +#[instrument(skip_all)] +pub(crate) fn cmd_absorb( + ui: &mut Ui, + command: &CommandHelper, + args: &AbsorbArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + + let source_commit = workspace_command.resolve_single_rev(ui, &args.from)?; + let destinations = workspace_command + .parse_union_revsets(ui, &args.into)? + .resolve()?; + + let matcher = workspace_command + .parse_file_patterns(ui, &args.paths)? + .to_matcher(); + + let repo = workspace_command.repo().as_ref(); + let source = AbsorbSource::from_commit(repo, source_commit)?; + let selected_trees = split_hunks_to_trees( + ui, + repo, + &source, + &destinations, + &matcher, + workspace_command.path_converter(), + ) + .block_on()?; + workspace_command.check_rewritable(selected_trees.keys())?; + + let mut tx = workspace_command.start_transaction(); + let (rewritten_commits, num_rebased) = + absorb_hunks(tx.repo_mut(), &source, selected_trees, command.settings())?; + + if let Some(mut formatter) = ui.status_formatter() { + if !rewritten_commits.is_empty() { + writeln!(formatter, "Absorbed changes into these revisions:")?; + let template = tx.commit_summary_template(); + for commit in rewritten_commits.iter().rev() { + write!(formatter, " ")?; + template.format(commit, formatter.as_mut())?; + writeln!(formatter)?; + } + } + if num_rebased > 0 { + writeln!(formatter, "Rebased {num_rebased} descendant commits.")?; + } + } + + tx.finish( + ui, + format!("absorb changes into {} commits", rewritten_commits.len()), + )?; + Ok(()) +} + +#[derive(Clone, Debug)] +struct AbsorbSource { + commit: Commit, + parent_tree: MergedTree, +} + +impl AbsorbSource { + fn from_commit(repo: &dyn Repo, commit: Commit) -> BackendResult { + let parent_tree = commit.parent_tree(repo)?; + Ok(AbsorbSource { + commit, + parent_tree, + }) + } +} + +/// Builds trees to be merged into destination commits by splitting source +/// changes based on file annotation. +async fn split_hunks_to_trees( + ui: &Ui, + repo: &dyn Repo, + source: &AbsorbSource, + destinations: &Rc, + matcher: &dyn Matcher, + path_converter: &RepoPathUiConverter, +) -> Result, CommandError> { + let mut selected_trees: HashMap = HashMap::new(); + + let left_tree = &source.parent_tree; + let right_tree = source.commit.tree()?; + // TODO: enable copy tracking if we add support for annotate and merge + let copy_records = CopyRecords::default(); + let tree_diff = left_tree.diff_stream_with_copies(&right_tree, matcher, ©_records); + let mut diff_stream = materialized_diff_stream(repo.store(), tree_diff); + while let Some(entry) = diff_stream.next().await { + let left_path = entry.path.source(); + let right_path = entry.path.target(); + let (left_value, right_value) = entry.values?; + let (left_text, executable) = match to_file_value(left_value) { + Ok(Some(mut value)) => (value.read(left_path)?, value.executable), + Ok(None) => continue, + Err(reason) => { + let ui_path = path_converter.format_file_path(left_path); + writeln!(ui.warning_default(), "Skipping {ui_path}: {reason}")?; + continue; + } + }; + let right_text = match to_file_value(right_value) { + Ok(Some(mut value)) => value.read(right_path)?, + Ok(None) => continue, + Err(reason) => { + let ui_path = path_converter.format_file_path(right_path); + writeln!(ui.warning_default(), "Skipping {ui_path}: {reason}")?; + continue; + } + }; + + // Compute annotation of parent (= left) content to map right hunks + let annotation = get_annotation_with_file_content( + repo, + source.commit.id(), + destinations, + left_path, + left_text.clone(), + )?; + let annotation_ranges = annotation + .compact_line_ranges() + .filter_map(|(commit_id, range)| Some((commit_id?, range))) + .collect_vec(); + let diff = Diff::by_line([&left_text, &right_text]); + let selected_ranges = split_file_hunks(&annotation_ranges, &diff); + // Build trees containing parent (= left) contents + selected hunks + for (&commit_id, ranges) in &selected_ranges { + let tree_builder = selected_trees + .entry(commit_id.clone()) + .or_insert_with(|| MergedTreeBuilder::new(left_tree.id().clone())); + let new_text = combine_texts(&left_text, &right_text, ranges); + let id = repo + .store() + .write_file(left_path, &mut new_text.as_slice()) + .await?; + tree_builder.set_or_remove( + left_path.to_owned(), + Merge::normal(TreeValue::File { id, executable }), + ); + } + } + + Ok(selected_trees) +} + +type SelectedRange = (Range, Range); + +/// Maps `diff` hunks to commits based on the left `annotation_ranges`. The +/// `annotation_ranges` should be compacted. +fn split_file_hunks<'a>( + mut annotation_ranges: &[(&'a CommitId, Range)], + diff: &Diff, +) -> HashMap<&'a CommitId, Vec> { + debug_assert!(annotation_ranges.iter().all(|(_, range)| !range.is_empty())); + let mut selected_ranges: HashMap<&CommitId, Vec<_>> = HashMap::new(); + let mut diff_hunk_ranges = diff + .hunk_ranges() + .filter(|hunk| hunk.kind == DiffHunkKind::Different); + while !annotation_ranges.is_empty() { + let Some(hunk) = diff_hunk_ranges.next() else { + break; + }; + let [left_range, right_range]: &[_; 2] = hunk.ranges[..].try_into().unwrap(); + assert!(!left_range.is_empty() || !right_range.is_empty()); + if right_range.is_empty() { + // If the hunk is pure deletion, it can be mapped to multiple + // overlapped annotation ranges unambiguously. + let skip = annotation_ranges + .iter() + .take_while(|(_, range)| range.end <= left_range.start) + .count(); + annotation_ranges = &annotation_ranges[skip..]; + let pre_overlap = annotation_ranges + .iter() + .take_while(|(_, range)| range.end < left_range.end) + .count(); + let maybe_overlapped_ranges = annotation_ranges.get(..pre_overlap + 1); + annotation_ranges = &annotation_ranges[pre_overlap..]; + let Some(overlapped_ranges) = maybe_overlapped_ranges else { + continue; + }; + // Ensure that the ranges are contiguous and include the start. + let all_covered = overlapped_ranges + .iter() + .try_fold(left_range.start, |prev_end, (_, cur)| { + (cur.start <= prev_end).then_some(cur.end) + }) + .inspect(|&last_end| assert!(left_range.end <= last_end)) + .is_some(); + if all_covered { + for (commit_id, cur_range) in overlapped_ranges { + let start = cmp::max(cur_range.start, left_range.start); + let end = cmp::min(cur_range.end, left_range.end); + assert!(start < end); + let selected = selected_ranges.entry(commit_id).or_default(); + selected.push((start..end, right_range.clone())); + } + } + } else { + // In other cases, the hunk should be included in an annotation + // range to map it unambiguously. Skip any pre-overlapped ranges. + let skip = annotation_ranges + .iter() + .take_while(|(_, range)| range.end < left_range.end) + .count(); + annotation_ranges = &annotation_ranges[skip..]; + let Some((commit_id, cur_range)) = annotation_ranges.first() else { + continue; + }; + let contained = cur_range.start <= left_range.start && left_range.end <= cur_range.end; + // If the hunk is pure insertion, it can be mapped to two distinct + // annotation ranges, which is ambiguous. + let ambiguous = cur_range.end == left_range.start + && annotation_ranges + .get(1) + .is_some_and(|(_, next_range)| next_range.start == left_range.end); + if contained && !ambiguous { + let selected = selected_ranges.entry(commit_id).or_default(); + selected.push((left_range.clone(), right_range.clone())); + } + } + } + selected_ranges +} + +/// Constructs new text by replacing `text1` range with `text2` range for each +/// selected `(range1, range2)` pairs. +fn combine_texts(text1: &[u8], text2: &[u8], selected_ranges: &[SelectedRange]) -> BString { + itertools::chain!( + [(0..0, 0..0)], + selected_ranges.iter().cloned(), + [(text1.len()..text1.len(), text2.len()..text2.len())], + ) + .tuple_windows() + // Copy unchanged hunk from text1 and current hunk from text2 + .map(|((prev1, _), (cur1, cur2))| (prev1.end..cur1.start, cur2)) + .flat_map(|(range1, range2)| [&text1[range1], &text2[range2]]) + .collect() +} + +/// Merges selected trees into the specified commits. +fn absorb_hunks( + repo: &mut MutableRepo, + source: &AbsorbSource, + mut selected_trees: HashMap, + settings: &UserSettings, +) -> BackendResult<(Vec, usize)> { + let store = repo.store().clone(); + let mut rewritten_commits = Vec::new(); + let mut num_rebased = 0; + // Rewrite commits in topological order so that descendant commits wouldn't + // be rewritten multiple times. + repo.transform_descendants( + settings, + selected_trees.keys().cloned().collect(), + |rewriter| { + // Remove selected hunks from the source commit by reparent() + if rewriter.old_commit().id() == source.commit.id() { + // TODO: should we abandon the source if it's discardable? + rewriter.reparent(settings)?.write()?; + num_rebased += 1; + return Ok(()); + } + let Some(tree_builder) = selected_trees.remove(rewriter.old_commit().id()) else { + rewriter.rebase(settings)?.write()?; + num_rebased += 1; + return Ok(()); + }; + // Merge hunks between source parent tree and selected tree + let selected_tree_id = tree_builder.write_tree(&store)?; + let commit_builder = rewriter.rebase(settings)?; + let destination_tree = store.get_root_tree(commit_builder.tree_id())?; + let selected_tree = store.get_root_tree(&selected_tree_id)?; + let new_tree = destination_tree.merge(&source.parent_tree, &selected_tree)?; + let mut predecessors = commit_builder.predecessors().to_vec(); + predecessors.push(source.commit.id().clone()); + let new_commit = commit_builder + .set_tree_id(new_tree.id()) + .set_predecessors(predecessors) + .write()?; + rewritten_commits.push(new_commit); + Ok(()) + }, + )?; + Ok((rewritten_commits, num_rebased)) +} + +struct FileValue { + id: FileId, + executable: bool, + reader: Box, +} + +impl FileValue { + fn read(&mut self, path: &RepoPath) -> BackendResult { + let mut buf = Vec::new(); + self.reader + .read_to_end(&mut buf) + .map_err(|err| BackendError::ReadFile { + path: path.to_owned(), + id: self.id.clone(), + source: err.into(), + })?; + Ok(buf.into()) + } +} + +fn to_file_value(value: MaterializedTreeValue) -> Result, String> { + match value { + MaterializedTreeValue::Absent => Ok(None), // New or deleted file + MaterializedTreeValue::AccessDenied(err) => Err(format!("Access is denied: {err}")), + MaterializedTreeValue::File { + id, + executable, + reader, + } => Ok(Some(FileValue { + id, + executable, + reader, + })), + MaterializedTreeValue::Symlink { .. } => Err("Is a symlink".into()), + MaterializedTreeValue::FileConflict { .. } + | MaterializedTreeValue::OtherConflict { .. } => Err("Is a conflict".into()), + MaterializedTreeValue::GitSubmodule(_) => Err("Is a Git submodule".into()), + MaterializedTreeValue::Tree(_) => panic!("diff should not contain trees"), + } +} + +#[cfg(test)] +mod tests { + use maplit::hashmap; + + use super::*; + + #[test] + fn test_split_file_hunks_empty_or_single_line() { + let commit_id1 = &CommitId::from_hex("111111"); + + // unchanged + assert_eq!(split_file_hunks(&[], &Diff::by_line(["", ""])), hashmap! {}); + + // insert single line + assert_eq!( + split_file_hunks(&[], &Diff::by_line(["", "2X\n"])), + hashmap! {} + ); + // delete single line + assert_eq!( + split_file_hunks(&[(commit_id1, 0..3)], &Diff::by_line(["1a\n", ""])), + hashmap! { commit_id1 => vec![(0..3, 0..0)] } + ); + // modify single line + assert_eq!( + split_file_hunks(&[(commit_id1, 0..3)], &Diff::by_line(["1a\n", "1AA\n"])), + hashmap! { commit_id1 => vec![(0..3, 0..4)] } + ); + } + + #[test] + fn test_split_file_hunks_single_range() { + let commit_id1 = &CommitId::from_hex("111111"); + + // insert first, middle, and last lines + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6)], + &Diff::by_line(["1a\n1b\n", "1X\n1a\n1Y\n1b\n1Z\n"]) + ), + hashmap! { + commit_id1 => vec![(0..0, 0..3), (3..3, 6..9), (6..6, 12..15)], + } + ); + // delete first, middle, and last lines + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..15)], + &Diff::by_line(["1a\n1b\n1c\n1d\n1e\n1f\n", "1b\n1d\n1f\n"]) + ), + hashmap! { + commit_id1 => vec![(0..3, 0..0), (6..9, 3..3), (12..15, 6..6)], + } + ); + // modify non-contiguous lines + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..12)], + &Diff::by_line(["1a\n1b\n1c\n1d\n", "1A\n1b\n1C\n1d\n"]) + ), + hashmap! { commit_id1 => vec![(0..3, 0..3), (6..9, 6..9)] } + ); + } + + #[test] + fn test_split_file_hunks_contiguous_ranges_insert() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // insert first line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1X\n1a\n1b\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(0..0, 0..3)] } + ); + // insert middle line to first range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1X\n1b\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(3..3, 3..6)] } + ); + // insert middle line between ranges (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n3X\n2a\n2b\n"]) + ), + hashmap! {} + ); + // insert middle line to second range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n2X\n2b\n"]) + ), + hashmap! { commit_id2 => vec![(9..9, 9..12)] } + ); + // insert last line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n2b\n2X\n"]) + ), + hashmap! { commit_id2 => vec![(12..12, 12..15)] } + ); + } + + #[test] + fn test_split_file_hunks_contiguous_ranges_delete() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // delete first line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1b\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(0..3, 0..0)] } + ); + // delete middle line from first range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(3..6, 3..3)] } + ); + // delete middle line from second range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2b\n"]) + ), + hashmap! { commit_id2 => vec![(6..9, 6..6)] } + ); + // delete last line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n"]) + ), + hashmap! { commit_id2 => vec![(9..12, 9..9)] } + ); + // delete first and last lines + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1b\n2a\n"]) + ), + hashmap! { + commit_id1 => vec![(0..3, 0..0)], + commit_id2 => vec![(9..12, 6..6)], + } + ); + + // delete across ranges (split first annotation range) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n"]) + ), + hashmap! { + commit_id1 => vec![(3..6, 3..3)], + commit_id2 => vec![(6..12, 3..3)], + } + ); + // delete middle lines across ranges (split both annotation ranges) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n2b\n"]) + ), + hashmap! { + commit_id1 => vec![(3..6, 3..3)], + commit_id2 => vec![(6..9, 3..3)], + } + ); + // delete across ranges (split second annotation range) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "2b\n"]) + ), + hashmap! { + commit_id1 => vec![(0..6, 0..0)], + commit_id2 => vec![(6..9, 0..0)], + } + ); + + // delete all + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", ""]) + ), + hashmap! { + commit_id1 => vec![(0..6, 0..0)], + commit_id2 => vec![(6..12, 0..0)], + } + ); + } + + #[test] + fn test_split_file_hunks_contiguous_ranges_modify() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // modify first line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(0..3, 0..3)] } + ); + // modify middle line of first range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1B\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(3..6, 3..6)] } + ); + // modify middle lines of both ranges (ambiguous) + // ('hg absorb' accepts this) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1B\n2A\n2b\n"]) + ), + hashmap! {} + ); + // modify middle line of second range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2A\n2b\n"]) + ), + hashmap! { commit_id2 => vec![(6..9, 6..9)] } + ); + // modify last line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2a\n2B\n"]) + ), + hashmap! { commit_id2 => vec![(9..12, 9..12)] } + ); + // modify first and last lines + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n2a\n2B\n"]) + ), + hashmap! { + commit_id1 => vec![(0..3, 0..3)], + commit_id2 => vec![(9..12, 9..12)], + } + ); + } + + #[test] + fn test_split_file_hunks_contiguous_ranges_modify_insert() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // modify first range, insert adjacent middle line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1B\n1X\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(0..6, 0..9)] } + ); + // modify second range, insert adjacent middle line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2X\n2A\n2B\n"]) + ), + hashmap! { commit_id2 => vec![(6..12, 6..15)] } + ); + // modify second range, insert last line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2A\n2B\n2X\n"]) + ), + hashmap! { commit_id2 => vec![(6..12, 6..15)] } + ); + // modify first and last lines (unambiguous), insert middle line between + // ranges (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n3X\n2a\n2B\n"]) + ), + hashmap! { + commit_id1 => vec![(0..3, 0..3)], + commit_id2 => vec![(9..12, 12..15)], + } + ); + } + + #[test] + fn test_split_file_hunks_contiguous_ranges_modify_delete() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // modify first line, delete adjacent middle line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1A\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(0..6, 0..3)] } + ); + // modify last line, delete adjacent middle line + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1b\n2B\n"]) + ), + hashmap! { commit_id2 => vec![(6..12, 6..9)] } + ); + // modify first and last lines, delete middle line from first range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1A\n2a\n2B\n"]) + ), + hashmap! { + commit_id1 => vec![(0..6, 0..3)], + commit_id2 => vec![(9..12, 6..9)], + } + ); + // modify first and last lines, delete middle line from second range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1A\n1b\n2B\n"]) + ), + hashmap! { + commit_id1 => vec![(0..3, 0..3)], + commit_id2 => vec![(6..12, 6..9)], + } + ); + // modify middle line, delete adjacent middle line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), (commit_id2, 6..12)], + &Diff::by_line(["1a\n1b\n2a\n2b\n", "1a\n1B\n2b\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_ranges_insert() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // insert middle line to first range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0a\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(6..6, 6..9)] } + ); + // insert middle line to second range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0a\n2X\n2a\n2b\n"]) + ), + hashmap! { commit_id2 => vec![(9..9, 9..12)] } + ); + // insert middle lines to both ranges + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0a\n2X\n2a\n2b\n"]) + ), + hashmap! { + commit_id1 => vec![(6..6, 6..9)], + commit_id2 => vec![(9..9, 12..15)], + } + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_ranges_insert_modify_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // insert middle line to first range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0A\n2a\n2b\n"]) + ), + hashmap! {} + ); + // insert middle line to second range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0A\n2X\n2a\n2b\n"]) + ), + hashmap! {} + ); + // insert middle lines to both ranges, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n1X\n0A\n2X\n2a\n2b\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_ranges_delete() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // delete middle line from first range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0a\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(3..6, 3..3)] } + ); + // delete middle line from second range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0a\n2b\n"]) + ), + hashmap! { commit_id2 => vec![(9..12, 9..9)] } + ); + // delete middle lines from both ranges + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0a\n2b\n"]) + ), + hashmap! { + commit_id1 => vec![(3..6, 3..3)], + commit_id2 => vec![(9..12, 6..6)], + } + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_ranges_delete_modify_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // delete middle line from first range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0A\n2a\n2b\n"]) + ), + hashmap! {} + ); + // delete middle line from second range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0A\n2b\n"]) + ), + hashmap! {} + ); + // delete middle lines from both ranges, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n0A\n2b\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_ranges_delete_delete_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // 'hg absorb' accepts these, but it seems better to reject them as + // ambiguous. Masked lines cannot be deleted. + + // delete middle line from first range, delete masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n2a\n2b\n"]) + ), + hashmap! {} + ); + // delete middle line from second range, delete masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n2b\n"]) + ), + hashmap! {} + ); + // delete middle lines from both ranges, delete masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n2b\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_ranges_modify() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // modify middle line of first range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0a\n2a\n2b\n"]) + ), + hashmap! { commit_id1 => vec![(3..6, 3..6)] } + ); + // modify middle line of second range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0a\n2A\n2b\n"]) + ), + hashmap! { commit_id2 => vec![(9..12, 9..12)] } + ); + // modify middle lines of both ranges + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0a\n2A\n2b\n"]) + ), + hashmap! { + commit_id1 => vec![(3..6, 3..6)], + commit_id2 => vec![(9..12, 9..12)], + } + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_ranges_modify_modify_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + + // modify middle line of first range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0A\n2a\n2b\n"]) + ), + hashmap! {} + ); + // modify middle line of second range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1b\n0A\n2A\n2b\n"]) + ), + hashmap! {} + ); + // modify middle lines to both ranges, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6), /* 6..9, */ (commit_id2, 9..15)], + &Diff::by_line(["1a\n1b\n0a\n2a\n2b\n", "1a\n1B\n0A\n2A\n2b\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_tail_range_insert() { + let commit_id1 = &CommitId::from_hex("111111"); + + // insert middle line to range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "1a\n1b\n1X\n0a\n"]) + ), + hashmap! { commit_id1 => vec![(6..6, 6..9)] } + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_tail_range_insert_modify_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + + // insert middle line to range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "1a\n1b\n1X\n0A\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_tail_range_delete() { + let commit_id1 = &CommitId::from_hex("111111"); + + // delete middle line from range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "1a\n0a\n"]) + ), + hashmap! { commit_id1 => vec![(3..6, 3..3)] } + ); + // delete all lines from range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "0a\n"]) + ), + hashmap! { commit_id1 => vec![(0..6, 0..0)] } + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_tail_range_delete_modify_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + + // delete middle line from range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "1a\n0A\n"]) + ), + hashmap! {} + ); + // delete all lines from range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "0A\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_tail_range_delete_delete_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + + // 'hg absorb' accepts these, but it seems better to reject them as + // ambiguous. Masked lines cannot be deleted. + + // delete middle line from range, delete masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "1a\n"]) + ), + hashmap! {} + ); + // delete all lines from range, delete masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", ""]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_tail_range_modify() { + let commit_id1 = &CommitId::from_hex("111111"); + + // modify middle line of range + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "1a\n1B\n0a\n"]) + ), + hashmap! { commit_id1 => vec![(3..6, 3..6)] } + ); + } + + #[test] + fn test_split_file_hunks_non_contiguous_tail_range_modify_modify_masked() { + let commit_id1 = &CommitId::from_hex("111111"); + + // modify middle line of range, modify masked line (ambiguous) + assert_eq!( + split_file_hunks( + &[(commit_id1, 0..6) /* , 6..9 */], + &Diff::by_line(["1a\n1b\n0a\n", "1a\n1B\n0A\n"]) + ), + hashmap! {} + ); + } + + #[test] + fn test_split_file_hunks_multiple_edits() { + let commit_id1 = &CommitId::from_hex("111111"); + let commit_id2 = &CommitId::from_hex("222222"); + let commit_id3 = &CommitId::from_hex("333333"); + + assert_eq!( + split_file_hunks( + &[ + (commit_id1, 0..3), // 1a => 1A + (commit_id2, 3..6), // 2a => 2a + (commit_id1, 6..15), // 1b 1c 1d => 1B 1d + (commit_id3, 15..21), // 3a 3b => 3X 3A 3b 3Y + ], + &Diff::by_line([ + "1a\n2a\n1b\n1c\n1d\n3a\n3b\n", + "1A\n2a\n1B\n1d\n3X\n3A\n3b\n3Y\n" + ]) + ), + hashmap! { + commit_id1 => vec![(0..3, 0..3), (6..12, 6..9)], + commit_id3 => vec![(15..18, 12..18), (21..21, 21..24)], + } + ); + } + + #[test] + fn test_combine_texts() { + assert_eq!(combine_texts(b"", b"", &[]), ""); + assert_eq!(combine_texts(b"foo", b"bar", &[]), "foo"); + assert_eq!(combine_texts(b"foo", b"bar", &[(0..3, 0..3)]), "bar"); + + assert_eq!( + combine_texts( + b"1a\n2a\n1b\n1c\n1d\n3a\n3b\n", + b"1A\n2a\n1B\n1d\n3X\n3A\n3b\n3Y\n", + &[(0..3, 0..3), (6..12, 6..9)] + ), + "1A\n2a\n1B\n1d\n3a\n3b\n" + ); + assert_eq!( + combine_texts( + b"1a\n2a\n1b\n1c\n1d\n3a\n3b\n", + b"1A\n2a\n1B\n1d\n3X\n3A\n3b\n3Y\n", + &[(15..18, 12..18), (21..21, 21..24)] + ), + "1a\n2a\n1b\n1c\n1d\n3X\n3A\n3b\n3Y\n" + ); + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 7bf08b2b82..e473df6687 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -13,6 +13,7 @@ // limitations under the License. mod abandon; +mod absorb; mod backout; #[cfg(feature = "bench")] mod bench; @@ -76,6 +77,7 @@ use crate::ui::Ui; #[command(after_long_help = help::show_keyword_hint_after_help())] enum Command { Abandon(abandon::AbandonArgs), + Absorb(absorb::AbsorbArgs), Backout(backout::BackoutArgs), #[cfg(feature = "bench")] #[command(subcommand)] @@ -189,6 +191,7 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co let subcommand = Command::from_arg_matches(command_helper.matches()).unwrap(); match &subcommand { Command::Abandon(args) => abandon::cmd_abandon(ui, command_helper, args), + Command::Absorb(args) => absorb::cmd_absorb(ui, command_helper, args), Command::Backout(args) => backout::cmd_backout(ui, command_helper, args), #[cfg(feature = "bench")] Command::Bench(args) => bench::cmd_bench(ui, command_helper, args), diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 8575bb7780..bbaa425e02 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -13,6 +13,7 @@ This document contains the help content for the `jj` command-line program. * [`jj`↴](#jj) * [`jj abandon`↴](#jj-abandon) +* [`jj absorb`↴](#jj-absorb) * [`jj backout`↴](#jj-backout) * [`jj bookmark`↴](#jj-bookmark) * [`jj bookmark create`↴](#jj-bookmark-create) @@ -121,6 +122,7 @@ To get started, see the tutorial at https://martinvonz.github.io/jj/latest/tutor ###### **Subcommands:** * `abandon` — Abandon a revision +* `absorb` — Move changes from a revision into the stack of mutable revisions * `backout` — Apply the reverse of a revision on top of another revision * `bookmark` — Manage bookmarks [default alias: b] * `commit` — Update the description and create a new change on top @@ -220,6 +222,33 @@ If a working-copy commit gets abandoned, it will be given a new, empty commit. T +## `jj absorb` + +Move changes from a revision into the stack of mutable revisions + +This command splits changes in the source revision and moves each change to the closest mutable ancestor where the corresponding lines were modified last. If the destination revision cannot be determined unambiguously, the change will be left in the source revision. + +The modification made by `jj absorb` can be reviewed by `jj op show -p`. + +**Usage:** `jj absorb [OPTIONS] [PATHS]...` + +###### **Arguments:** + +* `` — Move only changes to these paths (instead of all paths) + +###### **Options:** + +* `-f`, `--from ` — Source revision to absorb from + + Default value: `@` +* `-t`, `--into ` — Destination revisions to absorb into + + Only ancestors of the source revision will be considered. + + Default value: `mutable()` + + + ## `jj backout` Apply the reverse of a revision on top of another revision diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index 2fa6d904b9..d5c332bfd3 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -9,6 +9,7 @@ fn test_no_forgotten_test_files() { } mod test_abandon_command; +mod test_absorb_command; mod test_acls; mod test_advance_bookmarks; mod test_alias; diff --git a/cli/tests/test_absorb_command.rs b/cli/tests/test_absorb_command.rs new file mode 100644 index 0000000000..d3a55f5a59 --- /dev/null +++ b/cli/tests/test_absorb_command.rs @@ -0,0 +1,636 @@ +// 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::path::Path; + +use crate::common::TestEnvironment; + +#[test] +fn test_absorb_simple() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["describe", "-m0"]); + std::fs::write(repo_path.join("file1"), "").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n1b\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m2"]); + std::fs::write(repo_path.join("file1"), "1a\n1b\n2a\n2b\n").unwrap(); + + // Insert first and last lines + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "1X\n1a\n1b\n2a\n2b\n2Z\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + zsuskuln 3c319ca8 2 + kkmpptxz 6b722c47 1 + Rebased 1 descendant commits. + Working copy now at: mzvwutvl dfb52c7a (empty) (no description set) + Parent commit : zsuskuln 3c319ca8 2 + "); + + // Modify middle line in hunk + std::fs::write(repo_path.join("file1"), "1X\n1A\n1b\n2a\n2b\n2Z\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + kkmpptxz 1027ba02 1 + Rebased 2 descendant commits. + Working copy now at: mzvwutvl 7e01bf33 (empty) (no description set) + Parent commit : zsuskuln 669278a7 2 + "); + + // Remove middle line from hunk + std::fs::write(repo_path.join("file1"), "1X\n1A\n1b\n2a\n2Z\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + zsuskuln 660c7cac 2 + Rebased 1 descendant commits. + Working copy now at: mzvwutvl 39c8fe11 (empty) (no description set) + Parent commit : zsuskuln 660c7cac 2 + "); + + // Insert ambiguous line in between + std::fs::write(repo_path.join("file1"), "1X\n1A\n1b\nY\n2a\n2Z\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @"Nothing changed."); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, "mutable()"), @r" + @ mzvwutvl a0e56cbc (no description set) + │ diff --git a/file1 b/file1 + │ index 8653ca354d..88eb438902 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -1,5 +1,6 @@ + │ 1X + │ 1A + │ 1b + │ +Y + │ 2a + │ 2Z + ○ zsuskuln 660c7cac 2 + │ diff --git a/file1 b/file1 + │ index ed237b5112..8653ca354d 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -1,3 +1,5 @@ + │ 1X + │ 1A + │ 1b + │ +2a + │ +2Z + ○ kkmpptxz 1027ba02 1 + │ diff --git a/file1 b/file1 + │ index e69de29bb2..ed237b5112 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -1,0 +1,3 @@ + │ +1X + │ +1A + │ +1b + ○ qpvuntsm 1a4edb91 0 + │ diff --git a/file1 b/file1 + ~ new file mode 100644 + index 0000000000..e69de29bb2 + "); + insta::assert_snapshot!(get_evolog(&test_env, &repo_path, "description(1)"), @r" + ○ kkmpptxz 1027ba02 1 + ├─╮ + │ ○ mzvwutvl hidden 4624004f (no description set) + │ ○ mzvwutvl hidden dfb52c7a (empty) (no description set) + ○ │ kkmpptxz hidden 6b722c47 1 + ├─╮ + │ ○ mzvwutvl hidden 2342dbe2 (no description set) + │ ○ mzvwutvl hidden 2bc3d2ce (empty) (no description set) + ○ kkmpptxz hidden ee76d790 1 + ○ kkmpptxz hidden 677e62d5 (empty) 1 + "); + insta::assert_snapshot!(get_evolog(&test_env, &repo_path, "description(2)"), @r" + ○ zsuskuln 660c7cac 2 + ├─╮ + │ ○ mzvwutvl hidden cb78f902 (no description set) + │ ○ mzvwutvl hidden 7e01bf33 (empty) (no description set) + │ ○ mzvwutvl hidden 4624004f (no description set) + │ ○ mzvwutvl hidden dfb52c7a (empty) (no description set) + ○ │ zsuskuln hidden 669278a7 2 + ○ │ zsuskuln hidden 3c319ca8 2 + ├─╮ + │ ○ mzvwutvl hidden 2342dbe2 (no description set) + │ ○ mzvwutvl hidden 2bc3d2ce (empty) (no description set) + ○ zsuskuln hidden cca09b4d 2 + ○ zsuskuln hidden 7b092471 (empty) 2 + "); +} + +#[test] +fn test_absorb_replace_single_line_hunk() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["describe", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m2"]); + std::fs::write(repo_path.join("file1"), "2a\n1a\n2b\n").unwrap(); + + // Replace single-line hunk, which produces a conflict right now. If our + // merge logic were based on interleaved delta, the hunk would be applied + // cleanly. + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "2a\n1A\n2b\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + qpvuntsm 9661b868 (conflict) 1 + Rebased 2 descendant commits. + Working copy now at: zsuskuln f10b6e4e (empty) (no description set) + Parent commit : kkmpptxz bed2d032 2 + New conflicts appeared in these commits: + qpvuntsm 9661b868 (conflict) 1 + To resolve the conflicts, start by updating to it: + jj new qpvuntsm + Then use `jj resolve`, or edit the conflict markers in the file directly. + Once the conflicts are resolved, you may want to inspect the result with `jj diff`. + Then run `jj squash` to move the resolution into the conflicted commit. + "); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, "mutable()"), @r" + @ zsuskuln f10b6e4e (empty) (no description set) + ○ kkmpptxz bed2d032 2 + │ diff --git a/file1 b/file1 + │ index 0000000000..2f87e8e465 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -1,10 +1,3 @@ + │ -<<<<<<< Conflict 1 of 1 + │ -%%%%%%% Changes from base to side #1 + │ --2a + │ - 1a + │ --2b + │ -+++++++ Contents of side #2 + │ 2a + │ 1A + │ 2b + │ ->>>>>>> Conflict 1 of 1 ends + × qpvuntsm 9661b868 (conflict) 1 + │ diff --git a/file1 b/file1 + ~ new file mode 100644 + index 0000000000..0000000000 + --- /dev/null + +++ b/file1 + @@ -1,0 +1,10 @@ + +<<<<<<< Conflict 1 of 1 + +%%%%%%% Changes from base to side #1 + +-2a + + 1a + +-2b + ++++++++ Contents of side #2 + +2a + +1A + +2b + +>>>>>>> Conflict 1 of 1 ends + "); +} + +#[test] +fn test_absorb_merge() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["describe", "-m0"]); + std::fs::write(repo_path.join("file1"), "0a\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n1b\n0a\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m2", "description(0)"]); + std::fs::write(repo_path.join("file1"), "0a\n2a\n2b\n").unwrap(); + + let (_stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &["new", "-m3", "description(1)", "description(2)"], + ); + insta::assert_snapshot!(stderr, @r" + Working copy now at: mzvwutvl 08898161 (empty) 3 + Parent commit : kkmpptxz 7e9df299 1 + Parent commit : zsuskuln baf056cf 2 + Added 0 files, modified 1 files, removed 0 files + "); + + // Modify first and last lines, absorb from merge + std::fs::write(repo_path.join("file1"), "1A\n1b\n0a\n2a\n2B\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + zsuskuln 71d1ee56 2 + kkmpptxz 4d379399 1 + Rebased 1 descendant commits. + Working copy now at: mzvwutvl 9db19b54 (empty) 3 + Parent commit : kkmpptxz 4d379399 1 + Parent commit : zsuskuln 71d1ee56 2 + "); + + // Add hunk to merge revision + std::fs::write(repo_path.join("file2"), "3a\n").unwrap(); + + // Absorb into merge + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file2"), "3A\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + mzvwutvl e93c0210 3 + Rebased 1 descendant commits. + Working copy now at: yqosqzyt 1b10dfa4 (empty) (no description set) + Parent commit : mzvwutvl e93c0210 3 + "); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, "mutable()"), @r" + @ yqosqzyt 1b10dfa4 (empty) (no description set) + ○ mzvwutvl e93c0210 3 + ├─╮ diff --git a/file2 b/file2 + │ │ new file mode 100644 + │ │ index 0000000000..44442d2d7b + │ │ --- /dev/null + │ │ +++ b/file2 + │ │ @@ -1,0 +1,1 @@ + │ │ +3A + │ ○ zsuskuln 71d1ee56 2 + │ │ diff --git a/file1 b/file1 + │ │ index eb6e8821f1..4907935b9f 100644 + │ │ --- a/file1 + │ │ +++ b/file1 + │ │ @@ -1,1 +1,3 @@ + │ │ 0a + │ │ +2a + │ │ +2B + ○ │ kkmpptxz 4d379399 1 + ├─╯ diff --git a/file1 b/file1 + │ index eb6e8821f1..902dd8ef13 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -1,1 +1,3 @@ + │ +1A + │ +1b + │ 0a + ○ qpvuntsm 3777b700 0 + │ diff --git a/file1 b/file1 + ~ new file mode 100644 + index 0000000000..eb6e8821f1 + --- /dev/null + +++ b/file1 + @@ -1,0 +1,1 @@ + +0a + "); +} + +#[test] +fn test_absorb_conflict() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["describe", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n1b\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "root()"]); + std::fs::write(repo_path.join("file1"), "2a\n2b\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["rebase", "-r@", "-ddescription(1)"]); + insta::assert_snapshot!(stderr, @r" + Rebased 1 commits onto destination + Working copy now at: kkmpptxz 24d6d0f8 (conflict) (no description set) + Parent commit : qpvuntsm 3619e4e5 1 + Added 0 files, modified 1 files, removed 0 files + There are unresolved conflicts at these paths: + file1 2-sided conflict + New conflicts appeared in these commits: + kkmpptxz 24d6d0f8 (conflict) (no description set) + To resolve the conflicts, start by updating to it: + jj new kkmpptxz + Then use `jj resolve`, or edit the conflict markers in the file directly. + Once the conflicts are resolved, you may want to inspect the result with `jj diff`. + Then run `jj squash` to move the resolution into the conflicted commit. + "); + + let conflict_content = + String::from_utf8(std::fs::read(repo_path.join("file1")).unwrap()).unwrap(); + insta::assert_snapshot!(conflict_content, @r" + <<<<<<< Conflict 1 of 1 + %%%%%%% Changes from base to side #1 + +1a + +1b + +++++++ Contents of side #2 + 2a + 2b + >>>>>>> Conflict 1 of 1 ends + "); + + // Cannot absorb from conflict + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Warning: Skipping file1: Is a conflict + Nothing changed. + "); + + // Cannot absorb from resolved conflict + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "1A\n1b\n2a\n2B\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Warning: Skipping file1: Is a conflict + Nothing changed. + "); +} + +#[test] +fn test_absorb_file_mode() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["describe", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["file", "chmod", "x", "file1"]); + + // Modify content and mode + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "1A\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["file", "chmod", "n", "file1"]); + + // Mode change shouldn't be absorbed + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + qpvuntsm 991365da 1 + Rebased 1 descendant commits. + Working copy now at: zsuskuln 77de368e (no description set) + Parent commit : qpvuntsm 991365da 1 + "); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, "mutable()"), @r" + @ zsuskuln 77de368e (no description set) + │ diff --git a/file1 b/file1 + │ old mode 100755 + │ new mode 100644 + ○ qpvuntsm 991365da 1 + │ diff --git a/file1 b/file1 + ~ new file mode 100755 + index 0000000000..268de3f3ec + --- /dev/null + +++ b/file1 + @@ -1,0 +1,1 @@ + +1A + "); +} + +#[test] +fn test_absorb_from_into() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n1b\n1c\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m2"]); + std::fs::write(repo_path.join("file1"), "1a\n2a\n1b\n1c\n2b\n").unwrap(); + + // Line "X" and "Z" have unambiguous adjacent line within the destinations + // range. Line "Y" doesn't have such line. + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "1a\nX\n2a\n1b\nY\n1c\n2b\nZ\n").unwrap(); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb", "--into=@-"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + kkmpptxz 91df4543 2 + Rebased 1 descendant commits. + Working copy now at: zsuskuln d5424357 (no description set) + Parent commit : kkmpptxz 91df4543 2 + "); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, "@-::"), @r" + @ zsuskuln d5424357 (no description set) + │ diff --git a/file1 b/file1 + │ index faf62af049..c2d0b12547 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -2,6 +2,7 @@ + │ X + │ 2a + │ 1b + │ +Y + │ 1c + │ 2b + │ Z + ○ kkmpptxz 91df4543 2 + │ diff --git a/file1 b/file1 + ~ index 352e9b3794..faf62af049 100644 + --- a/file1 + +++ b/file1 + @@ -1,3 +1,7 @@ + 1a + +X + +2a + 1b + 1c + +2b + +Z + "); + + // Absorb all lines from the working-copy parent. An empty commit won't be + // discarded because "absorb" isn't a command to squash revisions, but to + // move hunks. + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb", "--from=@-"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + rlvkpnrz 3a5fd02e 1 + Rebased 2 descendant commits. + Working copy now at: zsuskuln 53ce490b (no description set) + Parent commit : kkmpptxz c94cd773 (empty) 2 + "); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, "mutable()"), @r" + @ zsuskuln 53ce490b (no description set) + │ diff --git a/file1 b/file1 + │ index faf62af049..c2d0b12547 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -2,6 +2,7 @@ + │ X + │ 2a + │ 1b + │ +Y + │ 1c + │ 2b + │ Z + ○ kkmpptxz c94cd773 (empty) 2 + ○ rlvkpnrz 3a5fd02e 1 + │ diff --git a/file1 b/file1 + │ new file mode 100644 + │ index 0000000000..faf62af049 + │ --- /dev/null + │ +++ b/file1 + │ @@ -1,0 +1,7 @@ + │ +1a + │ +X + │ +2a + │ +1b + │ +1c + │ +2b + │ +Z + ○ qpvuntsm 230dd059 (empty) (no description set) + │ + ~ + "); +} + +#[test] +fn test_absorb_paths() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["describe", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n").unwrap(); + std::fs::write(repo_path.join("file2"), "1a\n").unwrap(); + + // Modify both files + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "1A\n").unwrap(); + std::fs::write(repo_path.join("file2"), "1A\n").unwrap(); + + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb", "unknown"]); + insta::assert_snapshot!(stderr, @"Nothing changed."); + + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb", "file1"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + qpvuntsm ae044adb 1 + Rebased 1 descendant commits. + Working copy now at: kkmpptxz c6f31836 (no description set) + Parent commit : qpvuntsm ae044adb 1 + "); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, "mutable()"), @r" + @ kkmpptxz c6f31836 (no description set) + │ diff --git a/file2 b/file2 + │ index a8994dc188..268de3f3ec 100644 + │ --- a/file2 + │ +++ b/file2 + │ @@ -1,1 +1,1 @@ + │ -1a + │ +1A + ○ qpvuntsm ae044adb 1 + │ diff --git a/file1 b/file1 + ~ new file mode 100644 + index 0000000000..268de3f3ec + --- /dev/null + +++ b/file1 + @@ -1,0 +1,1 @@ + +1A + diff --git a/file2 b/file2 + new file mode 100644 + index 0000000000..a8994dc188 + --- /dev/null + +++ b/file2 + @@ -1,0 +1,1 @@ + +1a + "); +} + +#[test] +fn test_absorb_immutable() { + 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"); + test_env.add_config("revset-aliases.'immutable_heads()' = 'present(main)'"); + + test_env.jj_cmd_ok(&repo_path, &["describe", "-m1"]); + std::fs::write(repo_path.join("file1"), "1a\n1b\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new", "-m2"]); + test_env.jj_cmd_ok(&repo_path, &["bookmark", "set", "-r@-", "main"]); + std::fs::write(repo_path.join("file1"), "1a\n1b\n2a\n2b\n").unwrap(); + + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("file1"), "1A\n1b\n2a\n2B\n").unwrap(); + + // Immutable revisions are excluded by default + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["absorb"]); + insta::assert_snapshot!(stderr, @r" + Absorbed changes into these revisions: + kkmpptxz d80e3c2a 2 + Rebased 1 descendant commits. + Working copy now at: mzvwutvl 3021153d (no description set) + Parent commit : kkmpptxz d80e3c2a 2 + "); + + // Immutable revisions shouldn't be rewritten + let stderr = test_env.jj_cmd_failure(&repo_path, &["absorb", "--into=all()"]); + insta::assert_snapshot!(stderr, @r" + Error: Commit 3619e4e52fce is immutable + Hint: Could not modify commit: qpvuntsm 3619e4e5 main | 1 + Hint: Pass `--ignore-immutable` or configure the set of immutable commits via `revset-aliases.immutable_heads()`. + "); + + insta::assert_snapshot!(get_diffs(&test_env, &repo_path, ".."), @r" + @ mzvwutvl 3021153d (no description set) + │ diff --git a/file1 b/file1 + │ index 75e4047831..428796ca20 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -1,4 +1,4 @@ + │ -1a + │ +1A + │ 1b + │ 2a + │ 2B + ○ kkmpptxz d80e3c2a 2 + │ diff --git a/file1 b/file1 + │ index 8c5268f893..75e4047831 100644 + │ --- a/file1 + │ +++ b/file1 + │ @@ -1,2 +1,4 @@ + │ 1a + │ 1b + │ +2a + │ +2B + ◆ qpvuntsm 3619e4e5 1 + │ diff --git a/file1 b/file1 + ~ new file mode 100644 + index 0000000000..8c5268f893 + --- /dev/null + +++ b/file1 + @@ -1,0 +1,2 @@ + +1a + +1b + "); +} + +fn get_diffs(test_env: &TestEnvironment, repo_path: &Path, revision: &str) -> String { + let template = r#"format_commit_summary_with_refs(self, "") ++ "\n""#; + test_env.jj_cmd_success(repo_path, &["log", "-r", revision, "-T", template, "--git"]) +} + +fn get_evolog(test_env: &TestEnvironment, repo_path: &Path, revision: &str) -> String { + let template = r#"format_commit_summary_with_refs(self, "") ++ "\n""#; + test_env.jj_cmd_success(repo_path, &["evolog", "-r", revision, "-T", template]) +} diff --git a/cli/tests/test_immutable_commits.rs b/cli/tests/test_immutable_commits.rs index 7151ba52b2..e13a98b6fd 100644 --- a/cli/tests/test_immutable_commits.rs +++ b/cli/tests/test_immutable_commits.rs @@ -186,14 +186,15 @@ fn test_rewrite_immutable_commands() { std::fs::write(repo_path.join("file2"), "merged").unwrap(); test_env.jj_cmd_ok(&repo_path, &["bookmark", "create", "main"]); test_env.jj_cmd_ok(&repo_path, &["new", "description(b)"]); + std::fs::write(repo_path.join("file"), "w").unwrap(); test_env.add_config(r#"revset-aliases."immutable_heads()" = "main""#); test_env.add_config(r#"revset-aliases."trunk()" = "main""#); // Log shows mutable commits, their parents, and trunk() by default - let stdout = test_env.jj_cmd_success(&repo_path, &["log"]); - insta::assert_snapshot!(stdout, @r###" - @ yqosqzyt test.user@example.com 2001-02-03 08:05:13 65147295 - │ (empty) (no description set) + let (stdout, _stderr) = test_env.jj_cmd_ok(&repo_path, &["log"]); + insta::assert_snapshot!(stdout, @r" + @ yqosqzyt test.user@example.com 2001-02-03 08:05:14 55641cc5 + │ (no description set) │ ◆ mzvwutvl test.user@example.com 2001-02-03 08:05:12 main 1d5af877 conflict ╭─┤ merge │ │ @@ -202,7 +203,7 @@ fn test_rewrite_immutable_commands() { ◆ kkmpptxz test.user@example.com 2001-02-03 08:05:10 72e1b68c │ b ~ - "###); + "); // abandon let stderr = test_env.jj_cmd_failure(&repo_path, &["abandon", "main"]); @@ -211,6 +212,13 @@ fn test_rewrite_immutable_commands() { Hint: Could not modify commit: mzvwutvl 1d5af877 main | (conflict) merge Hint: Pass `--ignore-immutable` or configure the set of immutable commits via `revset-aliases.immutable_heads()`. "#); + // absorb + let stderr = test_env.jj_cmd_failure(&repo_path, &["absorb", "--into=::@-"]); + insta::assert_snapshot!(stderr, @r" + Error: Commit 72e1b68cbcf2 is immutable + Hint: Could not modify commit: kkmpptxz 72e1b68c b + Hint: Pass `--ignore-immutable` or configure the set of immutable commits via `revset-aliases.immutable_heads()`. + "); // chmod let stderr = test_env.jj_cmd_failure(&repo_path, &["file", "chmod", "-r=main", "x", "file"]); insta::assert_snapshot!(stderr, @r#"