just playing with tangled
at main 481 lines 18 kB view raw
1// Copyright 2020 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. 14use std::collections::HashMap; 15use std::io::Write as _; 16 17use clap_complete::ArgValueCompleter; 18use jj_lib::backend::CommitId; 19use jj_lib::commit::Commit; 20use jj_lib::matchers::Matcher; 21use jj_lib::object_id::ObjectId as _; 22use jj_lib::repo::Repo as _; 23use jj_lib::rewrite::move_commits; 24use jj_lib::rewrite::CommitWithSelection; 25use jj_lib::rewrite::EmptyBehaviour; 26use jj_lib::rewrite::MoveCommitsLocation; 27use jj_lib::rewrite::MoveCommitsTarget; 28use jj_lib::rewrite::RebaseOptions; 29use jj_lib::rewrite::RebasedCommit; 30use jj_lib::rewrite::RewriteRefsOptions; 31use tracing::instrument; 32 33use crate::cli_util::compute_commit_location; 34use crate::cli_util::CommandHelper; 35use crate::cli_util::DiffSelector; 36use crate::cli_util::RevisionArg; 37use crate::cli_util::WorkspaceCommandHelper; 38use crate::cli_util::WorkspaceCommandTransaction; 39use crate::command_error::user_error_with_hint; 40use crate::command_error::CommandError; 41use crate::complete; 42use crate::description_util::add_trailers; 43use crate::description_util::description_template; 44use crate::description_util::edit_description; 45use crate::description_util::join_message_paragraphs; 46use crate::ui::Ui; 47 48/// Split a revision in two 49/// 50/// Starts a [diff editor] on the changes in the revision. Edit the right side 51/// of the diff until it has the content you want in the new revision. Once 52/// you close the editor, your edited content will replace the previous 53/// revision. The remaining changes will be put in a new revision on top. 54/// 55/// [diff editor]: 56/// https://jj-vcs.github.io/jj/latest/config/#editing-diffs 57/// 58/// If the change you split had a description, you will be asked to enter a 59/// change description for each commit. If the change did not have a 60/// description, the remaining changes will not get a description, and you will 61/// be asked for a description only for the selected changes. 62/// 63/// Splitting an empty commit is not supported because the same effect can be 64/// achieved with `jj new`. 65#[derive(clap::Args, Clone, Debug)] 66pub(crate) struct SplitArgs { 67 /// Interactively choose which parts to split 68 /// 69 /// This is the default if no filesets are provided. 70 #[arg(long, short)] 71 interactive: bool, 72 /// Specify diff editor to be used (implies --interactive) 73 #[arg(long, value_name = "NAME")] 74 tool: Option<String>, 75 /// The revision to split 76 #[arg( 77 long, short, 78 default_value = "@", 79 value_name = "REVSET", 80 add = ArgValueCompleter::new(complete::revset_expression_mutable), 81 )] 82 revision: RevisionArg, 83 /// The revision(s) to rebase onto (can be repeated to create a merge 84 /// commit) 85 #[arg( 86 long, 87 short, 88 conflicts_with = "parallel", 89 value_name = "REVSETS", 90 add = ArgValueCompleter::new(complete::revset_expression_all), 91 )] 92 destination: Option<Vec<RevisionArg>>, 93 /// The revision(s) to insert after (can be repeated to create a merge 94 /// commit) 95 #[arg( 96 long, 97 short = 'A', 98 visible_alias = "after", 99 conflicts_with = "destination", 100 conflicts_with = "parallel", 101 value_name = "REVSETS", 102 add = ArgValueCompleter::new(complete::revset_expression_all), 103 )] 104 insert_after: Option<Vec<RevisionArg>>, 105 /// The revision(s) to insert before (can be repeated to create a merge 106 /// commit) 107 #[arg( 108 long, 109 short = 'B', 110 visible_alias = "before", 111 conflicts_with = "destination", 112 conflicts_with = "parallel", 113 value_name = "REVSETS", 114 add = ArgValueCompleter::new(complete::revset_expression_mutable), 115 )] 116 insert_before: Option<Vec<RevisionArg>>, 117 /// The change description to use (don't open editor) 118 /// 119 /// The description is used for the commit with the selected changes. The 120 /// source commit description is kept unchanged. 121 #[arg(long = "message", short, value_name = "MESSAGE")] 122 message_paragraphs: Vec<String>, 123 /// Split the revision into two parallel revisions instead of a parent and 124 /// child 125 #[arg(long, short)] 126 parallel: bool, 127 /// Files matching any of these filesets are put in the selected changes 128 #[arg( 129 value_name = "FILESETS", 130 value_hint = clap::ValueHint::AnyPath, 131 add = ArgValueCompleter::new(complete::modified_revision_files), 132 )] 133 paths: Vec<String>, 134} 135 136impl SplitArgs { 137 /// Resolves the raw SplitArgs into the components necessary to run the 138 /// command. Returns an error if the command cannot proceed. 139 fn resolve( 140 &self, 141 ui: &Ui, 142 workspace_command: &WorkspaceCommandHelper, 143 ) -> Result<ResolvedSplitArgs, CommandError> { 144 let target_commit = workspace_command.resolve_single_rev(ui, &self.revision)?; 145 if target_commit.is_empty(workspace_command.repo().as_ref())? { 146 return Err(user_error_with_hint( 147 format!( 148 "Refusing to split empty commit {}.", 149 target_commit.id().hex() 150 ), 151 "Use `jj new` if you want to create another empty commit.", 152 )); 153 } 154 workspace_command.check_rewritable([target_commit.id()])?; 155 let matcher = workspace_command 156 .parse_file_patterns(ui, &self.paths)? 157 .to_matcher(); 158 let diff_selector = workspace_command.diff_selector( 159 ui, 160 self.tool.as_deref(), 161 self.interactive || self.paths.is_empty(), 162 )?; 163 let use_move_flags = self.destination.is_some() 164 || self.insert_after.is_some() 165 || self.insert_before.is_some(); 166 let (new_parent_ids, new_child_ids) = if use_move_flags { 167 compute_commit_location( 168 ui, 169 workspace_command, 170 self.destination.as_deref(), 171 self.insert_after.as_deref(), 172 self.insert_before.as_deref(), 173 "split-out commit", 174 )? 175 } else { 176 Default::default() 177 }; 178 Ok(ResolvedSplitArgs { 179 target_commit, 180 matcher, 181 diff_selector, 182 parallel: self.parallel, 183 use_move_flags, 184 new_parent_ids, 185 new_child_ids, 186 }) 187 } 188} 189 190struct ResolvedSplitArgs { 191 target_commit: Commit, 192 matcher: Box<dyn Matcher>, 193 diff_selector: DiffSelector, 194 parallel: bool, 195 use_move_flags: bool, 196 new_parent_ids: Vec<CommitId>, 197 new_child_ids: Vec<CommitId>, 198} 199 200#[instrument(skip_all)] 201pub(crate) fn cmd_split( 202 ui: &mut Ui, 203 command: &CommandHelper, 204 args: &SplitArgs, 205) -> Result<(), CommandError> { 206 let mut workspace_command = command.workspace_helper(ui)?; 207 let ResolvedSplitArgs { 208 target_commit, 209 matcher, 210 diff_selector, 211 parallel, 212 use_move_flags, 213 new_parent_ids, 214 new_child_ids, 215 } = args.resolve(ui, &workspace_command)?; 216 let text_editor = workspace_command.text_editor()?; 217 let mut tx = workspace_command.start_transaction(); 218 219 // Prompt the user to select the changes they want for the first commit. 220 let target = select_diff(ui, &tx, &target_commit, &matcher, &diff_selector)?; 221 222 // Create the first commit, which includes the changes selected by the user. 223 let first_commit = { 224 let mut commit_builder = tx.repo_mut().rewrite_commit(&target.commit).detach(); 225 commit_builder.set_tree_id(target.selected_tree.id()); 226 if use_move_flags { 227 commit_builder 228 // Generate a new change id so that the commit being split doesn't 229 // become divergent. 230 .generate_new_change_id(); 231 } 232 let description = if !args.message_paragraphs.is_empty() { 233 let description = join_message_paragraphs(&args.message_paragraphs); 234 if !description.is_empty() { 235 commit_builder.set_description(description); 236 add_trailers(ui, &tx, &commit_builder)? 237 } else { 238 description 239 } 240 } else { 241 let new_description = add_trailers(ui, &tx, &commit_builder)?; 242 commit_builder.set_description(new_description); 243 let temp_commit = commit_builder.write_hidden()?; 244 let intro = "Enter a description for the selected changes."; 245 let template = description_template(ui, &tx, intro, &temp_commit)?; 246 edit_description(&text_editor, &template)? 247 }; 248 commit_builder.set_description(description); 249 commit_builder.write(tx.repo_mut())? 250 }; 251 252 // Create the second commit, which includes everything the user didn't 253 // select. 254 let second_commit = { 255 let target_tree = target.commit.tree()?; 256 let new_tree = if parallel { 257 // Merge the original commit tree with its parent using the tree 258 // containing the user selected changes as the base for the merge. 259 // This results in a tree with the changes the user didn't select. 260 target_tree.merge(&target.selected_tree, &target.parent_tree)? 261 } else { 262 target_tree 263 }; 264 let parents = if parallel { 265 target.commit.parent_ids().to_vec() 266 } else { 267 vec![first_commit.id().clone()] 268 }; 269 let mut commit_builder = tx.repo_mut().rewrite_commit(&target.commit).detach(); 270 commit_builder 271 .set_parents(parents) 272 .set_tree_id(new_tree.id()); 273 if !use_move_flags { 274 commit_builder 275 // Generate a new change id so that the commit being split doesn't 276 // become divergent. 277 .generate_new_change_id(); 278 } 279 let description = if target.commit.description().is_empty() { 280 // If there was no description before, don't ask for one for the 281 // second commit. 282 "".to_string() 283 } else if !args.message_paragraphs.is_empty() { 284 // Just keep the original message unchanged 285 commit_builder.description().to_owned() 286 } else { 287 let new_description = add_trailers(ui, &tx, &commit_builder)?; 288 commit_builder.set_description(new_description); 289 let temp_commit = commit_builder.write_hidden()?; 290 let intro = "Enter a description for the remaining changes."; 291 let template = description_template(ui, &tx, intro, &temp_commit)?; 292 edit_description(&text_editor, &template)? 293 }; 294 commit_builder.set_description(description); 295 commit_builder.write(tx.repo_mut())? 296 }; 297 298 let (first_commit, second_commit, num_rebased) = if use_move_flags { 299 move_first_commit( 300 &mut tx, 301 &target, 302 first_commit, 303 second_commit, 304 new_parent_ids, 305 new_child_ids, 306 )? 307 } else { 308 rewrite_descendants(&mut tx, &target, first_commit, second_commit, parallel)? 309 }; 310 if let Some(mut formatter) = ui.status_formatter() { 311 if num_rebased > 0 { 312 writeln!(formatter, "Rebased {num_rebased} descendant commits")?; 313 } 314 write!(formatter, "Selected changes : ")?; 315 tx.write_commit_summary(formatter.as_mut(), &first_commit)?; 316 write!(formatter, "\nRemaining changes: ")?; 317 tx.write_commit_summary(formatter.as_mut(), &second_commit)?; 318 writeln!(formatter)?; 319 } 320 tx.finish(ui, format!("split commit {}", target.commit.id().hex()))?; 321 Ok(()) 322} 323 324fn move_first_commit( 325 tx: &mut WorkspaceCommandTransaction, 326 target: &CommitWithSelection, 327 mut first_commit: Commit, 328 mut second_commit: Commit, 329 new_parent_ids: Vec<CommitId>, 330 new_child_ids: Vec<CommitId>, 331) -> Result<(Commit, Commit, usize), CommandError> { 332 let mut rewritten_commits: HashMap<CommitId, CommitId> = HashMap::new(); 333 rewritten_commits.insert(target.commit.id().clone(), second_commit.id().clone()); 334 tx.repo_mut() 335 .transform_descendants(vec![target.commit.id().clone()], |rewriter| { 336 let old_commit_id = rewriter.old_commit().id().clone(); 337 let new_commit = rewriter.rebase()?.write()?; 338 rewritten_commits.insert(old_commit_id, new_commit.id().clone()); 339 Ok(()) 340 })?; 341 342 let new_parent_ids: Vec<_> = new_parent_ids 343 .iter() 344 .map(|commit_id| rewritten_commits.get(commit_id).unwrap_or(commit_id)) 345 .cloned() 346 .collect(); 347 let new_child_ids: Vec<_> = new_child_ids 348 .iter() 349 .map(|commit_id| rewritten_commits.get(commit_id).unwrap_or(commit_id)) 350 .cloned() 351 .collect(); 352 let stats = move_commits( 353 tx.repo_mut(), 354 &MoveCommitsLocation { 355 new_parent_ids, 356 new_child_ids, 357 target: MoveCommitsTarget::Commits(vec![first_commit.id().clone()]), 358 }, 359 &RebaseOptions { 360 empty: EmptyBehaviour::Keep, 361 rewrite_refs: RewriteRefsOptions { 362 delete_abandoned_bookmarks: false, 363 }, 364 simplify_ancestor_merge: false, 365 }, 366 )?; 367 368 // 1 for the transformation of the original commit to the second commit 369 // that was inserted in rewritten_commits 370 let mut num_new_rebased = 1; 371 if let Some(RebasedCommit::Rewritten(commit)) = stats.rebased_commits.get(first_commit.id()) { 372 first_commit = commit.clone(); 373 num_new_rebased += 1; 374 } 375 if let Some(RebasedCommit::Rewritten(commit)) = stats.rebased_commits.get(second_commit.id()) { 376 second_commit = commit.clone(); 377 } 378 379 let num_rebased = rewritten_commits.len() + stats.rebased_commits.len() 380 // don't count the commit generated by the split in the rebased commits 381 - num_new_rebased 382 // only count once a commit that may have been rewritten twice in the process 383 - rewritten_commits 384 .iter() 385 .filter(|(_, rewritten)| stats.rebased_commits.contains_key(rewritten)) 386 .count(); 387 388 Ok((first_commit, second_commit, num_rebased)) 389} 390 391fn rewrite_descendants( 392 tx: &mut WorkspaceCommandTransaction, 393 target: &CommitWithSelection, 394 first_commit: Commit, 395 second_commit: Commit, 396 parallel: bool, 397) -> Result<(Commit, Commit, usize), CommandError> { 398 let legacy_bookmark_behavior = tx.settings().get_bool("split.legacy-bookmark-behavior")?; 399 if legacy_bookmark_behavior { 400 // Mark the commit being split as rewritten to the second commit. This 401 // moves any bookmarks pointing to the target commit to the second 402 // commit. 403 tx.repo_mut() 404 .set_rewritten_commit(target.commit.id().clone(), second_commit.id().clone()); 405 } 406 let mut num_rebased = 0; 407 tx.repo_mut() 408 .transform_descendants(vec![target.commit.id().clone()], |mut rewriter| { 409 num_rebased += 1; 410 if parallel && legacy_bookmark_behavior { 411 // The old_parent is the second commit due to the rewrite above. 412 rewriter 413 .replace_parent(second_commit.id(), [first_commit.id(), second_commit.id()]); 414 } else if parallel { 415 rewriter.replace_parent(first_commit.id(), [first_commit.id(), second_commit.id()]); 416 } else { 417 rewriter.replace_parent(first_commit.id(), [second_commit.id()]); 418 } 419 rewriter.rebase()?.write()?; 420 Ok(()) 421 })?; 422 // Move the working copy commit (@) to the second commit for any workspaces 423 // where the target commit is the working copy commit. 424 for (name, working_copy_commit) in tx.base_repo().clone().view().wc_commit_ids() { 425 if working_copy_commit == target.commit.id() { 426 tx.repo_mut().edit(name.clone(), &second_commit)?; 427 } 428 } 429 430 Ok((first_commit, second_commit, num_rebased)) 431} 432 433/// Prompts the user to select the content they want in the first commit and 434/// returns the target commit and the tree corresponding to the selection. 435fn select_diff( 436 ui: &Ui, 437 tx: &WorkspaceCommandTransaction, 438 target_commit: &Commit, 439 matcher: &dyn Matcher, 440 diff_selector: &DiffSelector, 441) -> Result<CommitWithSelection, CommandError> { 442 let format_instructions = || { 443 format!( 444 "\ 445You are splitting a commit into two: {} 446 447The diff initially shows the changes in the commit you're splitting. 448 449Adjust the right side until it shows the contents you want to split into the 450new commit. 451The changes that are not selected will replace the original commit. 452", 453 tx.format_commit_summary(target_commit) 454 ) 455 }; 456 let parent_tree = target_commit.parent_tree(tx.repo())?; 457 let selected_tree_id = diff_selector.select( 458 &parent_tree, 459 &target_commit.tree()?, 460 matcher, 461 format_instructions, 462 )?; 463 let selection = CommitWithSelection { 464 commit: target_commit.clone(), 465 selected_tree: tx.repo().store().get_root_tree(&selected_tree_id)?, 466 parent_tree, 467 }; 468 if selection.is_full_selection() { 469 writeln!( 470 ui.warning_default(), 471 "All changes have been selected, so the original revision will become empty" 472 )?; 473 } else if selection.is_empty_selection() { 474 writeln!( 475 ui.warning_default(), 476 "No changes have been selected, so the new revision will be empty" 477 )?; 478 } 479 480 Ok(selection) 481}