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