Skip to content

Commit 1e58ca2

Browse files
committed
squash: add -A/-B/-d options
For example, with a commit tree like this one: @ kkrvspxk (empty) (no description set) ○ lpllnppl file4 │ A file4 ○ xynqvmyt file3 │ A file3 ○ wkrztqwl file2 │ A file2 ○ mzuowqnz file1 │ A file1 ♦ zzzzzzzz root() we can jj squash -f x:: -d m to squash xynqvmyt and all its descendant as a new commit onto mzuowqnz. @ youptqqn (empty) (no description set) ○ wkrztqwl file2 │ A file2 │ ○ vsonsouy file3 file4 ├─╯ A file3 │ A file4 ○ mzuowqnz file1 │ A file1 ♦ zzzzzzzz root() On the implementation side, I've chosen to prepare the destination commit before hand, and keep the squash algorithm mostly unmodified.
1 parent e8f9169 commit 1e58ca2

File tree

6 files changed

+806
-23
lines changed

6 files changed

+806
-23
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7474
output using template expressions, similar to `jj op log`. Also added
7575
`--no-op-diff` flag to suppress the operation diff.
7676

77+
* `jj squash` has gained `--insert-before`, `--insert-after`, and `--destination`
78+
options.
79+
7780
### Fixed bugs
7881

7982
* `jj git clone` now correctly fetches all tags, unless `--fetch-tags` is

cli/src/commands/squash.rs

Lines changed: 174 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
use std::collections::HashMap;
16+
1517
use clap_complete::ArgValueCandidates;
1618
use clap_complete::ArgValueCompleter;
1719
use indoc::formatdoc;
@@ -23,12 +25,15 @@ use jj_lib::object_id::ObjectId as _;
2325
use jj_lib::repo::Repo as _;
2426
use jj_lib::rewrite;
2527
use jj_lib::rewrite::CommitWithSelection;
28+
use jj_lib::rewrite::merge_commit_trees;
29+
use pollster::FutureExt as _;
2630
use tracing::instrument;
2731

2832
use crate::cli_util::CommandHelper;
2933
use crate::cli_util::DiffSelector;
3034
use crate::cli_util::RevisionArg;
3135
use crate::cli_util::WorkspaceCommandTransaction;
36+
use crate::cli_util::compute_commit_location;
3237
use crate::command_error::CommandError;
3338
use crate::command_error::user_error;
3439
use crate::command_error::user_error_with_hint;
@@ -72,6 +77,7 @@ pub(crate) struct SquashArgs {
7277
add = ArgValueCompleter::new(complete::revset_expression_mutable),
7378
)]
7479
revision: Option<RevisionArg>,
80+
7581
/// Revision(s) to squash from (default: @)
7682
#[arg(
7783
long, short,
@@ -80,6 +86,7 @@ pub(crate) struct SquashArgs {
8086
add = ArgValueCompleter::new(complete::revset_expression_mutable),
8187
)]
8288
from: Vec<RevisionArg>,
89+
8390
/// Revision to squash into (default: @)
8491
#[arg(
8592
long, short = 't',
@@ -89,30 +96,76 @@ pub(crate) struct SquashArgs {
8996
add = ArgValueCompleter::new(complete::revset_expression_mutable),
9097
)]
9198
into: Option<RevisionArg>,
99+
100+
/// The revision(s) to use as parent for the new commit (can be repeated
101+
/// to create a merge commit)
102+
#[arg(
103+
long,
104+
short,
105+
conflicts_with = "into",
106+
conflicts_with = "revision",
107+
value_name = "REVSETS",
108+
add = ArgValueCompleter::new(complete::revset_expression_all),
109+
)]
110+
destination: Option<Vec<RevisionArg>>,
111+
112+
/// The revision(s) to insert the new commit after (can be repeated to
113+
/// create a merge commit)
114+
#[arg(
115+
long,
116+
short = 'A',
117+
visible_alias = "after",
118+
conflicts_with = "destination",
119+
conflicts_with = "into",
120+
conflicts_with = "revision",
121+
value_name = "REVSETS",
122+
add = ArgValueCompleter::new(complete::revset_expression_all),
123+
)]
124+
insert_after: Option<Vec<RevisionArg>>,
125+
126+
/// The revision(s) to insert the new commit before (can be repeated to
127+
/// create a merge commit)
128+
#[arg(
129+
long,
130+
short = 'B',
131+
visible_alias = "before",
132+
conflicts_with = "destination",
133+
conflicts_with = "into",
134+
conflicts_with = "revision",
135+
value_name = "REVSETS",
136+
add = ArgValueCompleter::new(complete::revset_expression_mutable),
137+
)]
138+
insert_before: Option<Vec<RevisionArg>>,
139+
92140
/// The description to use for squashed revision (don't open editor)
93141
#[arg(long = "message", short, value_name = "MESSAGE")]
94142
message_paragraphs: Vec<String>,
143+
95144
/// Use the description of the destination revision and discard the
96145
/// description(s) of the source revision(s)
97146
#[arg(long, short, conflicts_with = "message_paragraphs")]
98147
use_destination_message: bool,
148+
99149
/// Interactively choose which parts to squash
100150
#[arg(long, short)]
101151
interactive: bool,
152+
102153
/// Specify diff editor to be used (implies --interactive)
103154
#[arg(
104155
long,
105156
value_name = "NAME",
106157
add = ArgValueCandidates::new(complete::diff_editors),
107158
)]
108159
tool: Option<String>,
160+
109161
/// Move only changes to these paths (instead of all paths)
110162
#[arg(
111163
value_name = "FILESETS",
112164
value_hint = clap::ValueHint::AnyPath,
113165
add = ArgValueCompleter::new(complete::squash_revision_files),
114166
)]
115167
paths: Vec<String>,
168+
116169
/// The source revision will not be abandoned
117170
#[arg(long, short)]
118171
keep_emptied: bool,
@@ -124,22 +177,31 @@ pub(crate) fn cmd_squash(
124177
command: &CommandHelper,
125178
args: &SquashArgs,
126179
) -> Result<(), CommandError> {
180+
let insert_destination_commit =
181+
args.destination.is_some() || args.insert_after.is_some() || args.insert_before.is_some();
182+
127183
let mut workspace_command = command.workspace_helper(ui)?;
128184

129185
let mut sources: Vec<Commit>;
130-
let destination;
131-
if !args.from.is_empty() || args.into.is_some() {
186+
let pre_existing_destination;
187+
188+
if !args.from.is_empty() || args.into.is_some() || insert_destination_commit {
132189
sources = if args.from.is_empty() {
133190
workspace_command.parse_revset(ui, &RevisionArg::AT)?
134191
} else {
135192
workspace_command.parse_union_revsets(ui, &args.from)?
136193
}
137194
.evaluate_to_commits()?
138195
.try_collect()?;
139-
destination = workspace_command
140-
.resolve_single_rev(ui, args.into.as_ref().unwrap_or(&RevisionArg::AT))?;
141-
// remove the destination from the sources
142-
sources.retain(|source| source.id() != destination.id());
196+
if insert_destination_commit {
197+
pre_existing_destination = None;
198+
} else {
199+
let destination = workspace_command
200+
.resolve_single_rev(ui, args.into.as_ref().unwrap_or(&RevisionArg::AT))?;
201+
// remove the destination from the sources
202+
sources.retain(|source| source.id() != destination.id());
203+
pre_existing_destination = Some(destination);
204+
}
143205
// Reverse the set so we apply the oldest commits first. It shouldn't affect the
144206
// result, but it avoids creating transient conflicts and is therefore probably
145207
// a little faster.
@@ -155,21 +217,83 @@ pub(crate) fn cmd_squash(
155217
));
156218
}
157219
sources = vec![source];
158-
destination = parents.pop().unwrap();
159-
}
220+
pre_existing_destination = Some(parents.pop().unwrap());
221+
};
160222

161-
let matcher = workspace_command
223+
workspace_command.check_rewritable(sources.iter().chain(&pre_existing_destination).ids())?;
224+
225+
// prepare the tx description before possibly rebasing the source commits
226+
let source_ids: Vec<_> = sources.iter().ids().collect();
227+
let tx_description = if let Some(destination) = &pre_existing_destination {
228+
format!("squash commits into {}", destination.id().hex())
229+
} else {
230+
match &source_ids[..] {
231+
[] => format!("squash {} commits", source_ids.len()),
232+
[id] => format!("squash commit {}", id.hex()),
233+
[first, others @ ..] => {
234+
format!("squash commit {} and {} more", first.hex(), others.len())
235+
}
236+
}
237+
};
238+
239+
let mut tx = workspace_command.start_transaction();
240+
let mut num_rebased = 0;
241+
let destination = if let Some(commit) = pre_existing_destination {
242+
commit
243+
} else {
244+
// create the new destination commit
245+
let (parent_ids, child_ids) = compute_commit_location(
246+
ui,
247+
tx.base_workspace_helper(),
248+
args.destination.as_deref(),
249+
args.insert_after.as_deref(),
250+
args.insert_before.as_deref(),
251+
"squashed commit",
252+
)?;
253+
let parent_commits: Vec<_> = parent_ids
254+
.iter()
255+
.map(|commit_id| {
256+
tx.base_workspace_helper()
257+
.repo()
258+
.store()
259+
.get_commit(commit_id)
260+
})
261+
.try_collect()?;
262+
let merged_tree = merge_commit_trees(tx.repo(), &parent_commits).block_on()?;
263+
let commit = tx
264+
.repo_mut()
265+
.new_commit(parent_ids.clone(), merged_tree.id())
266+
.write()?;
267+
let mut rewritten = HashMap::new();
268+
tx.repo_mut()
269+
.transform_descendants(child_ids, async |mut rewriter| {
270+
let old_commit_id = rewriter.old_commit().id().clone();
271+
for parent_id in &parent_ids {
272+
rewriter.replace_parent(parent_id, [commit.id()]);
273+
}
274+
let new_commit = rewriter.rebase().await?.write()?;
275+
rewritten.insert(old_commit_id, new_commit);
276+
num_rebased += 1;
277+
Ok(())
278+
})?;
279+
for source in &mut *sources {
280+
if let Some(rewritten_source) = rewritten.remove(source.id()) {
281+
*source = rewritten_source;
282+
}
283+
}
284+
commit
285+
};
286+
287+
let matcher = tx
288+
.base_workspace_helper()
162289
.parse_file_patterns(ui, &args.paths)?
163290
.to_matcher();
164291
let diff_selector =
165-
workspace_command.diff_selector(ui, args.tool.as_deref(), args.interactive)?;
166-
let text_editor = workspace_command.text_editor()?;
292+
tx.base_workspace_helper()
293+
.diff_selector(ui, args.tool.as_deref(), args.interactive)?;
294+
let text_editor = tx.base_workspace_helper().text_editor()?;
167295
let description = SquashedDescription::from_args(args);
168-
workspace_command
169-
.check_rewritable(sources.iter().chain(std::iter::once(&destination)).ids())?;
170296

171-
let mut tx = workspace_command.start_transaction();
172-
let tx_description = format!("squash commits into {}", destination.id().hex());
173297
let source_commits = select_diff(&tx, &sources, &destination, &matcher, &diff_selector)?;
174298
if let Some(squashed) = rewrite::squash_commits(
175299
tx.repo_mut(),
@@ -209,7 +333,7 @@ pub(crate) fn cmd_squash(
209333
ui,
210334
&tx,
211335
abandoned_commits,
212-
&destination,
336+
(!insert_destination_commit).then_some(&destination),
213337
&commit_builder,
214338
)?;
215339
// It's weird that commit.description() contains "JJ: " lines, but works.
@@ -222,12 +346,45 @@ pub(crate) fn cmd_squash(
222346
}
223347
};
224348
commit_builder.set_description(new_description);
225-
commit_builder.write(tx.repo_mut())?;
349+
if insert_destination_commit {
350+
// forget about the intermediate commit
351+
commit_builder.set_predecessors(
352+
commit_builder
353+
.predecessors()
354+
.iter()
355+
.filter(|p| p != &destination.id())
356+
.cloned()
357+
.collect(),
358+
);
359+
}
360+
let commit = commit_builder.write(tx.repo_mut())?;
361+
let num_rebased = tx.repo_mut().rebase_descendants()?;
362+
if let Some(mut formatter) = ui.status_formatter() {
363+
if insert_destination_commit {
364+
write!(formatter, "Created new commit ")?;
365+
tx.write_commit_summary(formatter.as_mut(), &commit)?;
366+
writeln!(formatter)?;
367+
}
368+
if num_rebased > 0 {
369+
writeln!(formatter, "Rebased {num_rebased} descendant commits")?;
370+
}
371+
}
226372
} else {
227373
if diff_selector.is_interactive() {
228374
return Err(user_error("No changes selected"));
229375
}
230376

377+
if let Some(mut formatter) = ui.status_formatter() {
378+
if insert_destination_commit {
379+
write!(formatter, "Created new commit ")?;
380+
tx.write_commit_summary(formatter.as_mut(), &destination)?;
381+
writeln!(formatter)?;
382+
}
383+
if num_rebased > 0 {
384+
writeln!(formatter, "Rebased {num_rebased} descendant commits")?;
385+
}
386+
}
387+
231388
if let [only_path] = &*args.paths {
232389
let no_rev_arg = args.revision.is_none() && args.from.is_empty() && args.into.is_none();
233390
if no_rev_arg

cli/src/description_util.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,14 @@ pub fn combine_messages_for_editing(
323323
ui: &Ui,
324324
tx: &WorkspaceCommandTransaction,
325325
sources: &[Commit],
326-
destination: &Commit,
326+
destination: Option<&Commit>,
327327
commit_builder: &DetachedCommitBuilder,
328328
) -> Result<String, CommandError> {
329329
let mut combined = String::new();
330-
combined.push_str("JJ: Description from the destination commit:\n");
331-
combined.push_str(destination.description());
330+
if let Some(destination) = destination {
331+
combined.push_str("JJ: Description from the destination commit:\n");
332+
combined.push_str(destination.description());
333+
}
332334
for commit in sources {
333335
combined.push_str("\nJJ: Description from source commit:\n");
334336
combined.push_str(commit.description());
@@ -338,7 +340,7 @@ pub fn combine_messages_for_editing(
338340
// show the user only trailers that were not in one of the squashed commits
339341
let old_trailers: Vec<_> = sources
340342
.iter()
341-
.chain(std::iter::once(destination))
343+
.chain(destination)
342344
.flat_map(|commit| parse_description_trailers(commit.description()))
343345
.collect();
344346
let commit = commit_builder.write_hidden()?;

cli/tests/[email protected]

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2588,6 +2588,9 @@ If a working-copy commit gets abandoned, it will be given a new, empty commit. T
25882588
* `-r`, `--revision <REVSET>` — Revision to squash into its parent (default: @)
25892589
* `-f`, `--from <REVSETS>` — Revision(s) to squash from (default: @)
25902590
* `-t`, `--into <REVSET>` [alias: `to`] — Revision to squash into (default: @)
2591+
* `-d`, `--destination <REVSETS>` — The revision(s) to use as parent for the new commit (can be repeated to create a merge commit)
2592+
* `-A`, `--insert-after <REVSETS>` [alias: `after`] — The revision(s) to insert the new commit after (can be repeated to create a merge commit)
2593+
* `-B`, `--insert-before <REVSETS>` [alias: `before`] — The revision(s) to insert the new commit before (can be repeated to create a merge commit)
25912594
* `-m`, `--message <MESSAGE>` — The description to use for squashed revision (don't open editor)
25922595
* `-u`, `--use-destination-message` — Use the description of the destination revision and discard the description(s) of the source revision(s)
25932596
* `-i`, `--interactive` — Interactively choose which parts to squash

cli/tests/test_immutable_commits.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,34 @@ fn test_rewrite_immutable_commands() {
538538
[EOF]
539539
[exit status: 1]
540540
"#);
541+
// squash --after
542+
let output = work_dir.run_jj(["squash", "--after=main-"]);
543+
insta::assert_snapshot!(output, @r#"
544+
------- stderr -------
545+
Error: Commit 4397373a0991 is immutable
546+
Hint: Could not modify commit: mzvwutvl 4397373a main | (conflict) merge
547+
Hint: Immutable commits are used to protect shared history.
548+
Hint: For more information, see:
549+
- https://jj-vcs.github.io/jj/latest/config/#set-of-immutable-commits
550+
- `jj help -k config`, "Set of immutable commits"
551+
Hint: This operation would rewrite 1 immutable commits.
552+
[EOF]
553+
[exit status: 1]
554+
"#);
555+
// squash --before
556+
let output = work_dir.run_jj(["squash", "--before=main"]);
557+
insta::assert_snapshot!(output, @r#"
558+
------- stderr -------
559+
Error: Commit 4397373a0991 is immutable
560+
Hint: Could not modify commit: mzvwutvl 4397373a main | (conflict) merge
561+
Hint: Immutable commits are used to protect shared history.
562+
Hint: For more information, see:
563+
- https://jj-vcs.github.io/jj/latest/config/#set-of-immutable-commits
564+
- `jj help -k config`, "Set of immutable commits"
565+
Hint: This operation would rewrite 1 immutable commits.
566+
[EOF]
567+
[exit status: 1]
568+
"#);
541569
// sign
542570
let output = work_dir.run_jj(["sign", "-r=main", "--config=signing.backend=test"]);
543571
insta::assert_snapshot!(output, @r#"

0 commit comments

Comments
 (0)