just playing with tangled
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;
15
16use jj_lib::object_id::ObjectId;
17use jj_lib::repo::Repo;
18use jj_lib::rewrite::merge_commit_trees;
19use tracing::instrument;
20
21use crate::cli_util::{CommandHelper, RevisionArg};
22use crate::command_error::CommandError;
23use crate::description_util::{description_template_for_commit, edit_description};
24use crate::ui::Ui;
25
26/// Split a revision in two
27///
28/// Starts a [diff editor] on the changes in the revision. Edit the right side
29/// of the diff until it has the content you want in the first revision. Once
30/// you close the editor, your edited content will replace the previous
31/// revision. The remaining changes will be put in a new revision on top.
32///
33/// [diff editor]:
34/// https://martinvonz.github.io/jj/latest/config/#editing-diffs
35///
36/// If the change you split had a description, you will be asked to enter a
37/// change description for each commit. If the change did not have a
38/// description, the second part will not get a description, and you will be
39/// asked for a description only for the first part.
40#[derive(clap::Args, Clone, Debug)]
41pub(crate) struct SplitArgs {
42 /// Interactively choose which parts to split. This is the default if no
43 /// paths are provided.
44 #[arg(long, short)]
45 interactive: bool,
46 /// Specify diff editor to be used (implies --interactive)
47 #[arg(long, value_name = "NAME")]
48 tool: Option<String>,
49 /// The revision to split
50 #[arg(long, short, default_value = "@")]
51 revision: RevisionArg,
52 /// Split the revision into two siblings instead of a parent and child.
53 #[arg(long, short)]
54 siblings: bool,
55 /// Put these paths in the first commit
56 #[arg(value_hint = clap::ValueHint::AnyPath)]
57 paths: Vec<String>,
58}
59
60#[instrument(skip_all)]
61pub(crate) fn cmd_split(
62 ui: &mut Ui,
63 command: &CommandHelper,
64 args: &SplitArgs,
65) -> Result<(), CommandError> {
66 let mut workspace_command = command.workspace_helper(ui)?;
67 let commit = workspace_command.resolve_single_rev(&args.revision)?;
68 workspace_command.check_rewritable([commit.id()])?;
69 let matcher = workspace_command
70 .parse_file_patterns(&args.paths)?
71 .to_matcher();
72 let diff_selector = workspace_command.diff_selector(
73 ui,
74 args.tool.as_deref(),
75 args.interactive || args.paths.is_empty(),
76 )?;
77 let mut tx = workspace_command.start_transaction();
78 let end_tree = commit.tree()?;
79 let base_tree = merge_commit_trees(tx.repo(), &commit.parents())?;
80 let instructions = format!(
81 "\
82You are splitting a commit into two: {}
83
84The diff initially shows the changes in the commit you're splitting.
85
86Adjust the right side until it shows the contents you want for the first commit.
87The remainder will be in the second commit. If you don't make any changes, then
88the operation will be aborted.
89",
90 tx.format_commit_summary(&commit)
91 );
92
93 // Prompt the user to select the changes they want for the first commit.
94 let selected_tree_id =
95 diff_selector.select(&base_tree, &end_tree, matcher.as_ref(), Some(&instructions))?;
96 if &selected_tree_id == commit.tree_id() && diff_selector.is_interactive() {
97 // The user selected everything from the original commit.
98 writeln!(ui.status(), "Nothing changed.")?;
99 return Ok(());
100 }
101 if selected_tree_id == base_tree.id() {
102 // The user selected nothing, so the first commit will be empty.
103 writeln!(
104 ui.warning_default(),
105 "The given paths do not match any file: {}",
106 args.paths.join(" ")
107 )?;
108 }
109
110 // Create the first commit, which includes the changes selected by the user.
111 let selected_tree = tx.repo().store().get_root_tree(&selected_tree_id)?;
112 let first_template = description_template_for_commit(
113 ui,
114 command.settings(),
115 tx.base_workspace_helper(),
116 "Enter a description for the first commit.",
117 commit.description(),
118 &base_tree,
119 &selected_tree,
120 )?;
121 let first_description = edit_description(tx.base_repo(), &first_template, command.settings())?;
122 let first_commit = tx
123 .mut_repo()
124 .rewrite_commit(command.settings(), &commit)
125 .set_tree_id(selected_tree_id)
126 .set_description(first_description)
127 .write()?;
128
129 // Create the second commit, which includes everything the user didn't
130 // select.
131 let (second_tree, second_base_tree) = if args.siblings {
132 // Merge the original commit tree with its parent using the tree
133 // containing the user selected changes as the base for the merge.
134 // This results in a tree with the changes the user didn't select.
135 (end_tree.merge(&selected_tree, &base_tree)?, &base_tree)
136 } else {
137 (end_tree, &selected_tree)
138 };
139 let second_commit_parents = if args.siblings {
140 commit.parent_ids().to_vec()
141 } else {
142 vec![first_commit.id().clone()]
143 };
144 let second_description = if commit.description().is_empty() {
145 // If there was no description before, don't ask for one for the second commit.
146 "".to_string()
147 } else {
148 let second_template = description_template_for_commit(
149 ui,
150 command.settings(),
151 tx.base_workspace_helper(),
152 "Enter a description for the second commit.",
153 commit.description(),
154 second_base_tree,
155 &second_tree,
156 )?;
157 edit_description(tx.base_repo(), &second_template, command.settings())?
158 };
159 let second_commit = tx
160 .mut_repo()
161 .rewrite_commit(command.settings(), &commit)
162 .set_parents(second_commit_parents)
163 .set_tree_id(second_tree.id())
164 // Generate a new change id so that the commit being split doesn't
165 // become divergent.
166 .generate_new_change_id()
167 .set_description(second_description)
168 .write()?;
169
170 // Mark the commit being split as rewritten to the second commit. As a
171 // result, if @ points to the commit being split, it will point to the
172 // second commit after the command finishes. This also means that any
173 // branches pointing to the commit being split are moved to the second
174 // commit.
175 tx.mut_repo()
176 .set_rewritten_commit(commit.id().clone(), second_commit.id().clone());
177 let mut num_rebased = 0;
178 tx.mut_repo().transform_descendants(
179 command.settings(),
180 vec![commit.id().clone()],
181 |mut rewriter| {
182 num_rebased += 1;
183 if args.siblings {
184 rewriter
185 .replace_parent(second_commit.id(), [first_commit.id(), second_commit.id()]);
186 }
187 // We don't need to do anything special for the non-siblings case
188 // since we already marked the original commit as rewritten.
189 rewriter.rebase(command.settings())?.write()?;
190 Ok(())
191 },
192 )?;
193
194 if let Some(mut formatter) = ui.status_formatter() {
195 if num_rebased > 0 {
196 writeln!(formatter, "Rebased {num_rebased} descendant commits")?;
197 }
198 write!(formatter, "First part: ")?;
199 tx.write_commit_summary(formatter.as_mut(), &first_commit)?;
200 write!(formatter, "\nSecond part: ")?;
201 tx.write_commit_summary(formatter.as_mut(), &second_commit)?;
202 writeln!(formatter)?;
203 }
204 tx.finish(ui, format!("split commit {}", commit.id().hex()))?;
205 Ok(())
206}