Skip to content

Commit

Permalink
cli: duplicate: add --destination, --insert-after, and `--insert-…
Browse files Browse the repository at this point in the history
…before` options
  • Loading branch information
bnjmnt4n committed Nov 15, 2024
1 parent 5138836 commit 5a793f6
Show file tree
Hide file tree
Showing 5 changed files with 2,261 additions and 30 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* Added the config setting `snapshot.auto-update-stale` for automatically
running `jj workspace update-stale` when applicable.

* `jj duplicate` now accepts `--destination`, `--insert-after` and
`--insert-before` options to customize the location of the duplicated
revisions.

### Fixed bugs

## [0.23.0] - 2024-11-06
Expand Down
218 changes: 194 additions & 24 deletions cli/src/commands/duplicate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@
// limitations under the License.

use std::io::Write;
use std::rc::Rc;

use indexmap::IndexMap;
use itertools::Itertools;
use jj_lib::backend::CommitId;
use jj_lib::commit::Commit;
use jj_lib::commit::CommitIteratorExt;
use jj_lib::repo::ReadonlyRepo;
use jj_lib::repo::Repo;
use jj_lib::revset::ResolvedRevsetExpression;
use jj_lib::revset::RevsetExpression;
use jj_lib::rewrite::duplicate_commits;
use jj_lib::rewrite::duplicate_commits_onto_parents;
use jj_lib::rewrite::DuplicateCommitsStats;
use tracing::instrument;

use crate::cli_util::short_commit_hash;
Expand All @@ -28,7 +34,19 @@ use crate::command_error::user_error;
use crate::command_error::CommandError;
use crate::ui::Ui;

/// Create a new change with the same content as an existing one
/// Create new changes with the same content as existing ones
///
/// When none of the `--destination`, `--insert-after`, or `--insert-before`
/// arguments are provided, commits will be duplicated onto their existing
/// parents or onto other newly duplicated commits.
///
/// When any of the `--destination`, `--insert-after`, or `--insert-before`
/// arguments are provided, the roots of the specified commits will be
/// duplicated onto the destination indicated by the arguments. Other specified
/// commits will be duplicated onto these newly duplicated commits. If the
/// `--insert-after` or `--insert-before` arguments are provided, the new
/// children indicated by the arguments will be rebased onto the heads of the
/// specified commits.
#[derive(clap::Args, Clone, Debug)]
pub(crate) struct DuplicateArgs {
/// The revision(s) to duplicate
Expand All @@ -37,6 +55,28 @@ pub(crate) struct DuplicateArgs {
/// Ignored (but lets you pass `-r` for consistency with other commands)
#[arg(short = 'r', hide = true, action = clap::ArgAction::Count)]
unused_revision: u8,
/// The revision(s) to duplicate onto (can be repeated to create a merge
/// commit)
#[arg(long, short)]
destination: Vec<RevisionArg>,
/// The revision(s) to insert after (can be repeated to create a merge
/// commit)
#[arg(
long,
short = 'A',
visible_alias = "after",
conflicts_with = "destination"
)]
insert_after: Vec<RevisionArg>,
/// The revision(s) to insert before (can be repeated to create a merge
/// commit)
#[arg(
long,
short = 'B',
visible_alias = "before",
conflicts_with = "destination"
)]
insert_before: Vec<RevisionArg>,
}

#[instrument(skip_all)]
Expand All @@ -57,37 +97,167 @@ pub(crate) fn cmd_duplicate(
if to_duplicate.last() == Some(workspace_command.repo().store().root_commit_id()) {
return Err(user_error("Cannot duplicate the root commit"));
}
let mut duplicated_old_to_new: IndexMap<&CommitId, Commit> = IndexMap::new();

let mut tx = workspace_command.start_transaction();
let base_repo = tx.base_repo().clone();
let store = base_repo.store();
let mut_repo = tx.repo_mut();
let parent_commit_ids: Vec<CommitId>;
let children_commit_ids: Vec<CommitId>;

for original_commit_id in to_duplicate.iter().rev() {
// Topological order ensures that any parents of `original_commit` are
// either not in `to_duplicate` or were already duplicated.
let original_commit = store.get_commit(original_commit_id)?;
let new_parents = original_commit
.parent_ids()
if !args.insert_before.is_empty() && !args.insert_after.is_empty() {
let parent_commits = workspace_command
.resolve_some_revsets_default_single(ui, &args.insert_after)?
.into_iter()
.collect_vec();
parent_commit_ids = parent_commits.iter().ids().cloned().collect();
let children_commits = workspace_command
.resolve_some_revsets_default_single(ui, &args.insert_before)?
.into_iter()
.collect_vec();
children_commit_ids = children_commits.iter().ids().cloned().collect();
workspace_command.check_rewritable(&children_commit_ids)?;
let children_expression = RevsetExpression::commits(children_commit_ids.clone());
let parents_expression = RevsetExpression::commits(parent_commit_ids.clone());
ensure_no_commit_loop(
workspace_command.repo(),
&children_expression,
&parents_expression,
)?;
} else if !args.insert_before.is_empty() {
let children_commits = workspace_command
.resolve_some_revsets_default_single(ui, &args.insert_before)?
.into_iter()
.collect_vec();
children_commit_ids = children_commits.iter().ids().cloned().collect();
workspace_command.check_rewritable(&children_commit_ids)?;
let children_expression = RevsetExpression::commits(children_commit_ids.clone());
let parents_expression = children_expression.parents();
ensure_no_commit_loop(
workspace_command.repo(),
&children_expression,
&parents_expression,
)?;
// Manually collect the parent commit IDs to preserve the order of parents.
parent_commit_ids = children_commits
.iter()
.flat_map(|commit| commit.parent_ids())
.unique()
.cloned()
.collect_vec();
} else if !args.insert_after.is_empty() {
let parent_commits = workspace_command
.resolve_some_revsets_default_single(ui, &args.insert_after)?
.into_iter()
.collect_vec();
parent_commit_ids = parent_commits.iter().ids().cloned().collect();
let parents_expression = RevsetExpression::commits(parent_commit_ids.clone());
let children_expression = parents_expression.children();
children_commit_ids = children_expression
.clone()
.evaluate(workspace_command.repo().as_ref())
.map_err(|err| err.expect_backend_error())?
.iter()
.map(|id| duplicated_old_to_new.get(id).map_or(id, |c| c.id()).clone())
.collect();
let new_commit = mut_repo
.rewrite_commit(command.settings(), &original_commit)
.generate_new_change_id()
.set_parents(new_parents)
.write()?;
duplicated_old_to_new.insert(original_commit_id, new_commit);
.try_collect()?;
workspace_command.check_rewritable(&children_commit_ids)?;
ensure_no_commit_loop(
workspace_command.repo(),
&children_expression,
&parents_expression,
)?;
} else if !args.destination.is_empty() {
let parent_commits = workspace_command
.resolve_some_revsets_default_single(ui, &args.destination)?
.into_iter()
.collect_vec();
parent_commit_ids = parent_commits.iter().ids().cloned().collect();
children_commit_ids = vec![];
} else {
parent_commit_ids = vec![];
children_commit_ids = vec![];
};

let mut tx = workspace_command.start_transaction();

if !parent_commit_ids.is_empty() {
for commit_id in &to_duplicate {
for parent_commit_id in &parent_commit_ids {
if tx.repo().index().is_ancestor(commit_id, parent_commit_id) {
writeln!(
ui.warning_default(),
"Duplicating commit {} as a descendant of itself",
short_commit_hash(commit_id)
)?;
break;
}
}
}

for commit_id in &to_duplicate {
for child_commit_id in &children_commit_ids {
if tx.repo().index().is_ancestor(child_commit_id, commit_id) {
writeln!(
ui.warning_default(),
"Duplicating commit {} as an ancestor of itself",
short_commit_hash(commit_id)
)?;
break;
}
}
}
}

let num_to_duplicate = to_duplicate.len();
let DuplicateCommitsStats {
duplicated_commits,
num_rebased,
} = if args.destination.is_empty()
&& args.insert_after.is_empty()
&& args.insert_before.is_empty()
{
duplicate_commits_onto_parents(command.settings(), tx.repo_mut(), &to_duplicate)?
} else {
duplicate_commits(
command.settings(),
tx.repo_mut(),
&to_duplicate,
&parent_commit_ids,
&children_commit_ids,
)?
};

if let Some(mut formatter) = ui.status_formatter() {
for (old_id, new_commit) in &duplicated_old_to_new {
for (old_id, new_commit) in &duplicated_commits {
write!(formatter, "Duplicated {} as ", short_commit_hash(old_id))?;
tx.write_commit_summary(formatter.as_mut(), new_commit)?;
writeln!(formatter)?;
}
if num_rebased > 0 {
writeln!(
ui.status(),
"Rebased {num_rebased} commits onto duplicated commits"
)?;
}
}
tx.finish(ui, format!("duplicate {num_to_duplicate} commit(s)"))?;
Ok(())
}

/// Ensure that there is no possible cycle between the potential children and
/// parents of the duplicated commits.
fn ensure_no_commit_loop(
repo: &ReadonlyRepo,
children_expression: &Rc<ResolvedRevsetExpression>,
parents_expression: &Rc<ResolvedRevsetExpression>,
) -> Result<(), CommandError> {
if let Some(commit_id) = children_expression
.dag_range_to(parents_expression)
.evaluate(repo)?
.iter()
.next()
{
let commit_id = commit_id?;
return Err(user_error(format!(
"Refusing to create a loop: commit {} would be both an ancestor and a descendant of \
the duplicated commits",
short_commit_hash(&commit_id),
)));
}
tx.finish(ui, format!("duplicate {} commit(s)", to_duplicate.len()))?;
Ok(())
}
17 changes: 13 additions & 4 deletions cli/tests/[email protected]
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
---
source: cli/tests/test_generate_md_cli_help.rs
description: "AUTO-GENERATED FILE, DO NOT EDIT. This cli reference is generated by a test as an `insta` snapshot. MkDocs includes this snapshot from docs/cli-reference.md."
snapshot_kind: text
---
<!-- BEGIN MARKDOWN-->

Expand Down Expand Up @@ -130,7 +129,7 @@ To get started, see the tutorial at https://martinvonz.github.io/jj/latest/tutor
* `describe`Update the change description or other metadata [aliases: desc]
* `diff`Compare file contents between two revisions
* `diffedit`Touch up the content changes in a revision with a diff editor
* `duplicate`Create a new change with the same content as an existing one
* `duplicate`Create new changes with the same content as existing ones
* `edit`Sets the specified revision as the working-copy revision
* `evolog`Show how a change has evolved over time
* `file`File operations
Expand Down Expand Up @@ -745,16 +744,26 @@ See `jj restore` if you want to move entire files from one revision to another.
## `jj duplicate`
Create a new change with the same content as an existing one
Create new changes with the same content as existing ones
**Usage:** `jj duplicate [REVISIONS]...`
When none of the `--destination`, `--insert-after`, or `--insert-before` arguments are provided, commits will be duplicated onto their existing parents or onto other newly duplicated commits.
When any of the `--destination`, `--insert-after`, or `--insert-before` arguments are provided, the roots of the specified commits will be duplicated onto the destination indicated by the arguments. Other specified commits will be duplicated onto these newly duplicated commits. If the `--insert-after` or `--insert-before` arguments are provided, the new children indicated by the arguments will be rebased onto the heads of the specified commits.
**Usage:** `jj duplicate [OPTIONS] [REVISIONS]...`
###### **Arguments:**
* `<REVISIONS>` — The revision(s) to duplicate
Default value: `@`
###### **Options:**
* `-d`, `--destination <DESTINATION>` — The revision(s) to duplicate onto (can be repeated to create a merge commit)
* `-A`, `--insert-after <INSERT_AFTER>` — The revision(s) to insert after (can be repeated to create a merge commit)
* `-B`, `--insert-before <INSERT_BEFORE>` — The revision(s) to insert before (can be repeated to create a merge commit)
## `jj edit`
Expand Down
Loading

0 comments on commit 5a793f6

Please sign in to comment.