just playing with tangled
at gvimdiff 301 lines 11 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::io::Write as _; 15 16use clap_complete::ArgValueCandidates; 17use clap_complete::ArgValueCompleter; 18use jj_lib::commit::Commit; 19use jj_lib::matchers::Matcher; 20use jj_lib::object_id::ObjectId as _; 21use jj_lib::repo::Repo as _; 22use jj_lib::rewrite::CommitWithSelection; 23use tracing::instrument; 24 25use crate::cli_util::CommandHelper; 26use crate::cli_util::DiffSelector; 27use crate::cli_util::RevisionArg; 28use crate::cli_util::WorkspaceCommandHelper; 29use crate::cli_util::WorkspaceCommandTransaction; 30use crate::command_error::user_error_with_hint; 31use crate::command_error::CommandError; 32use crate::complete; 33use crate::description_util::description_template; 34use crate::description_util::edit_description; 35use crate::ui::Ui; 36 37/// Split a revision in two 38/// 39/// Starts a [diff editor] on the changes in the revision. Edit the right side 40/// of the diff until it has the content you want in the first revision. Once 41/// you close the editor, your edited content will replace the previous 42/// revision. The remaining changes will be put in a new revision on top. 43/// 44/// [diff editor]: 45/// https://jj-vcs.github.io/jj/latest/config/#editing-diffs 46/// 47/// If the change you split had a description, you will be asked to enter a 48/// change description for each commit. If the change did not have a 49/// description, the second part will not get a description, and you will be 50/// asked for a description only for the first part. 51/// 52/// Splitting an empty commit is not supported because the same effect can be 53/// achieved with `jj new`. 54#[derive(clap::Args, Clone, Debug)] 55pub(crate) struct SplitArgs { 56 /// Interactively choose which parts to split 57 /// 58 /// This is the default if no filesets are provided. 59 #[arg(long, short)] 60 interactive: bool, 61 /// Specify diff editor to be used (implies --interactive) 62 #[arg(long, value_name = "NAME")] 63 tool: Option<String>, 64 /// The revision to split 65 #[arg( 66 long, short, 67 default_value = "@", 68 value_name = "REVSET", 69 add = ArgValueCandidates::new(complete::mutable_revisions) 70 )] 71 revision: RevisionArg, 72 /// Split the revision into two parallel revisions instead of a parent and 73 /// child 74 #[arg(long, short)] 75 parallel: bool, 76 /// Files matching any of these filesets are put in the first commit 77 #[arg( 78 value_name = "FILESETS", 79 value_hint = clap::ValueHint::AnyPath, 80 add = ArgValueCompleter::new(complete::modified_revision_files), 81 )] 82 paths: Vec<String>, 83} 84 85impl SplitArgs { 86 /// Resolves the raw SplitArgs into the components necessary to run the 87 /// command. Returns an error if the command cannot proceed. 88 fn resolve( 89 &self, 90 ui: &Ui, 91 workspace_command: &WorkspaceCommandHelper, 92 ) -> Result<ResolvedSplitArgs, CommandError> { 93 let target_commit = workspace_command.resolve_single_rev(ui, &self.revision)?; 94 if target_commit.is_empty(workspace_command.repo().as_ref())? { 95 return Err(user_error_with_hint( 96 format!( 97 "Refusing to split empty commit {}.", 98 target_commit.id().hex() 99 ), 100 "Use `jj new` if you want to create another empty commit.", 101 )); 102 } 103 workspace_command.check_rewritable([target_commit.id()])?; 104 let matcher = workspace_command 105 .parse_file_patterns(ui, &self.paths)? 106 .to_matcher(); 107 let diff_selector = workspace_command.diff_selector( 108 ui, 109 self.tool.as_deref(), 110 self.interactive || self.paths.is_empty(), 111 )?; 112 Ok(ResolvedSplitArgs { 113 target_commit, 114 matcher, 115 diff_selector, 116 parallel: self.parallel, 117 }) 118 } 119} 120 121struct ResolvedSplitArgs { 122 target_commit: Commit, 123 matcher: Box<dyn Matcher>, 124 diff_selector: DiffSelector, 125 parallel: bool, 126} 127 128#[instrument(skip_all)] 129pub(crate) fn cmd_split( 130 ui: &mut Ui, 131 command: &CommandHelper, 132 args: &SplitArgs, 133) -> Result<(), CommandError> { 134 let mut workspace_command = command.workspace_helper(ui)?; 135 let ResolvedSplitArgs { 136 target_commit, 137 matcher, 138 diff_selector, 139 parallel, 140 } = args.resolve(ui, &workspace_command)?; 141 let text_editor = workspace_command.text_editor()?; 142 let mut tx = workspace_command.start_transaction(); 143 144 // Prompt the user to select the changes they want for the first commit. 145 let target = select_diff(ui, &tx, &target_commit, &matcher, &diff_selector)?; 146 147 // Create the first commit, which includes the changes selected by the user. 148 let first_commit = { 149 let mut commit_builder = tx.repo_mut().rewrite_commit(&target.commit).detach(); 150 commit_builder.set_tree_id(target.selected_tree.id()); 151 if commit_builder.description().is_empty() { 152 commit_builder.set_description(tx.settings().get_string("ui.default-description")?); 153 } 154 let temp_commit = commit_builder.write_hidden()?; 155 let template = description_template( 156 ui, 157 &tx, 158 "Enter a description for the first commit.", 159 &temp_commit, 160 )?; 161 let description = edit_description(&text_editor, &template)?; 162 commit_builder.set_description(description); 163 commit_builder.write(tx.repo_mut())? 164 }; 165 166 // Create the second commit, which includes everything the user didn't 167 // select. 168 let second_commit = { 169 let target_tree = target.commit.tree()?; 170 let new_tree = if args.parallel { 171 // Merge the original commit tree with its parent using the tree 172 // containing the user selected changes as the base for the merge. 173 // This results in a tree with the changes the user didn't select. 174 target_tree.merge(&target.selected_tree, &target.parent_tree)? 175 } else { 176 target_tree 177 }; 178 let parents = if parallel { 179 target.commit.parent_ids().to_vec() 180 } else { 181 vec![first_commit.id().clone()] 182 }; 183 let mut commit_builder = tx.repo_mut().rewrite_commit(&target.commit).detach(); 184 commit_builder 185 .set_parents(parents) 186 .set_tree_id(new_tree.id()) 187 // Generate a new change id so that the commit being split doesn't 188 // become divergent. 189 .generate_new_change_id(); 190 let description = if target.commit.description().is_empty() { 191 // If there was no description before, don't ask for one for the 192 // second commit. 193 "".to_string() 194 } else { 195 let temp_commit = commit_builder.write_hidden()?; 196 let template = description_template( 197 ui, 198 &tx, 199 "Enter a description for the second commit.", 200 &temp_commit, 201 )?; 202 edit_description(&text_editor, &template)? 203 }; 204 commit_builder.set_description(description); 205 commit_builder.write(tx.repo_mut())? 206 }; 207 208 let legacy_bookmark_behavior = tx.settings().get_bool("split.legacy-bookmark-behavior")?; 209 if legacy_bookmark_behavior { 210 // Mark the commit being split as rewritten to the second commit. This 211 // moves any bookmarks pointing to the target commit to the second 212 // commit. 213 tx.repo_mut() 214 .set_rewritten_commit(target.commit.id().clone(), second_commit.id().clone()); 215 } 216 let mut num_rebased = 0; 217 tx.repo_mut() 218 .transform_descendants(vec![target.commit.id().clone()], |mut rewriter| { 219 num_rebased += 1; 220 if parallel && legacy_bookmark_behavior { 221 // The old_parent is the second commit due to the rewrite above. 222 rewriter 223 .replace_parent(second_commit.id(), [first_commit.id(), second_commit.id()]); 224 } else if parallel { 225 rewriter.replace_parent(first_commit.id(), [first_commit.id(), second_commit.id()]); 226 } else { 227 rewriter.replace_parent(first_commit.id(), [second_commit.id()]); 228 } 229 rewriter.rebase()?.write()?; 230 Ok(()) 231 })?; 232 // Move the working copy commit (@) to the second commit for any workspaces 233 // where the target commit is the working copy commit. 234 for (name, working_copy_commit) in tx.base_repo().clone().view().wc_commit_ids() { 235 if working_copy_commit == target.commit.id() { 236 tx.repo_mut().edit(name.clone(), &second_commit)?; 237 } 238 } 239 240 if let Some(mut formatter) = ui.status_formatter() { 241 if num_rebased > 0 { 242 writeln!(formatter, "Rebased {num_rebased} descendant commits")?; 243 } 244 write!(formatter, "First part: ")?; 245 tx.write_commit_summary(formatter.as_mut(), &first_commit)?; 246 write!(formatter, "\nSecond part: ")?; 247 tx.write_commit_summary(formatter.as_mut(), &second_commit)?; 248 writeln!(formatter)?; 249 } 250 tx.finish(ui, format!("split commit {}", target.commit.id().hex()))?; 251 Ok(()) 252} 253 254/// Prompts the user to select the content they want in the first commit and 255/// returns the target commit and the tree corresponding to the selection. 256fn select_diff( 257 ui: &Ui, 258 tx: &WorkspaceCommandTransaction, 259 target_commit: &Commit, 260 matcher: &dyn Matcher, 261 diff_selector: &DiffSelector, 262) -> Result<CommitWithSelection, CommandError> { 263 let format_instructions = || { 264 format!( 265 "\ 266You are splitting a commit into two: {} 267 268The diff initially shows the changes in the commit you're splitting. 269 270Adjust the right side until it shows the contents you want for the first commit. 271The remainder will be in the second commit. 272", 273 tx.format_commit_summary(target_commit) 274 ) 275 }; 276 let parent_tree = target_commit.parent_tree(tx.repo())?; 277 let selected_tree_id = diff_selector.select( 278 &parent_tree, 279 &target_commit.tree()?, 280 matcher, 281 format_instructions, 282 )?; 283 let selection = CommitWithSelection { 284 commit: target_commit.clone(), 285 selected_tree: tx.repo().store().get_root_tree(&selected_tree_id)?, 286 parent_tree, 287 }; 288 if selection.is_full_selection() { 289 writeln!( 290 ui.warning_default(), 291 "All changes have been selected, so the second commit will be empty" 292 )?; 293 } else if selection.is_empty_selection() { 294 writeln!( 295 ui.warning_default(), 296 "No changes have been selected, so the first commit will be empty" 297 )?; 298 } 299 300 Ok(selection) 301}