601 lines · 21.5 KB · Rust
RawDownload
// Copyright 2020 The Jujutsu Authors//// Licensed under the Apache License, Version 2.0 (the "License");// you may not use this file except in compliance with the License.// You may obtain a copy of the License at//// https://www.apache.org/licenses/LICENSE-2.0//// Unless required by applicable law or agreed to in writing, software// distributed under the License is distributed on an "AS IS" BASIS,// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.// See the License for the specific language governing permissions and// limitations under the License.use std::collections::HashMap;use std::io::Write as _; use clap_complete::ArgValueCandidates;use clap_complete::ArgValueCompleter;use jj_lib::backend::CommitId;use jj_lib::commit::Commit;use jj_lib::matchers::Matcher;use jj_lib::merge::Diff;use jj_lib::merge::Merge;use jj_lib::merged_tree::MergedTree;use jj_lib::object_id::ObjectId as _;use jj_lib::rewrite::CommitRewriter;use jj_lib::rewrite::CommitWithSelection;use jj_lib::rewrite::EmptyBehavior;use jj_lib::rewrite::MoveCommitsLocation;use jj_lib::rewrite::MoveCommitsTarget;use jj_lib::rewrite::RebaseOptions;use jj_lib::rewrite::RebasedCommit;use jj_lib::rewrite::RewriteRefsOptions;use jj_lib::rewrite::move_commits;use tracing::instrument; use crate::cli_util::CommandHelper;use crate::cli_util::DiffSelector;use crate::cli_util::RevisionArg;use crate::cli_util::WorkspaceCommandHelper;use crate::cli_util::WorkspaceCommandTransaction;use crate::cli_util::compute_commit_location;use crate::cli_util::print_unmatched_explicit_paths;use crate::command_error::CommandError;use crate::complete;use crate::description_util::add_trailers;use crate::description_util::description_template;use crate::description_util::edit_description;use crate::description_util::join_message_paragraphs;use crate::ui::Ui; /// Split a revision in two////// Starts a [diff editor] on the changes in the revision. Edit the right side/// of the diff until it has the content you want in the first commit. Once you/// close the editor, your revision will be split into two commits.////// [diff editor]:///     https://docs.jj-vcs.dev/latest/config/#editing-diffs////// By default, the selected changes stay in the original commit, and the/// remaining changes go into a new child commit:////// ```text/// L                 L'/// |                 |/// K (split)   =>    K" (remaining)/// |                 |/// J                 K' (selected)///                   |///                   J/// ```////// With `--parallel/-p`, the two parts become sibling commits instead of/// parent and child:////// ```text///                   L'/// L                / \/// |               K'  |  (selected)/// K (split)  =>   |   K" (remaining)/// |                \ //// J                 J/// ```////// With `-o`, `-A`, or `-B`, the selected changes are extracted into a new/// commit at the specified location, while the remaining changes stay in place:////// ```text/// M                 M'/// |                 |/// L                 L'/// |                 |/// K (split)   =>    K' (remaining, stays here)/// |                 |/// J                 J'///                   |///                   K" (selected, inserted before J with -B J)/// ```////// If the change you split had a description, you will be asked to enter a/// change description for each commit. If the change did not have a/// description, the second commit will not get a description, and you will be/// asked for a description only for the first commit.////// Splitting an empty commit is not supported because the same effect can be/// achieved with `jj new`.#[derive(clap::Args, Clone, Debug)]#[command(verbatim_doc_comment)]pub(crate) struct SplitArgs {    /// Interactively choose which parts to split    ///    /// This is the default if no filesets are provided.    #[arg(long, short)]    interactive: bool,     /// Specify diff editor to be used (implies --interactive)    #[arg(long, value_name = "NAME")]    #[arg(add = ArgValueCandidates::new(complete::diff_editors))]    tool: Option<String>,     /// The revision to split    #[arg(long, short, default_value = "@", value_name = "REVSET")]    #[arg(add = ArgValueCompleter::new(complete::revset_expression_mutable))]    revision: RevisionArg,     /// The revision(s) to rebase the selected changes onto (can be repeated to    /// create a merge commit)    ///    /// Extracts the selected changes into a new commit based on the given    /// revision(s). The remaining changes stay in the original commit's    /// location.    #[arg(        long,        visible_alias = "destination",        short,        visible_short_alias = 'd',        conflicts_with = "parallel",        value_name = "REVSETS"    )]    #[arg(add = ArgValueCompleter::new(complete::revset_expression_all))]    onto: Option<Vec<RevisionArg>>,     /// The revision(s) to insert after (can be repeated to create a merge    /// commit)    ///    /// Extracts the selected changes into a new commit inserted after the    /// given revision(s). The remaining changes stay in the original commit's    /// location.    #[arg(        long,        short = 'A',        visible_alias = "after",        conflicts_with_all = ["onto", "parallel"],        value_name = "REVSETS"    )]    #[arg(add = ArgValueCompleter::new(complete::revset_expression_all))]    insert_after: Option<Vec<RevisionArg>>,     /// The revision(s) to insert before (can be repeated to create a merge    /// commit)    ///    /// Extracts the selected changes into a new commit inserted before the    /// given revision(s). The remaining changes stay in the original commit's    /// location.    #[arg(        long,        short = 'B',        visible_alias = "before",        conflicts_with_all = ["onto", "parallel"],        value_name = "REVSETS"    )]    #[arg(add = ArgValueCompleter::new(complete::revset_expression_mutable))]    insert_before: Option<Vec<RevisionArg>>,     /// The change description to use for the selected changes (don't open    /// editor)    ///    /// Sets the description for the revision containing the selected changes.    /// The other revision will keep its original description, if any.    #[arg(long = "message", short, value_name = "MESSAGE")]    message_paragraphs: Option<Vec<String>>,     /// Open an editor to edit the change description(s)    ///    /// Forces an editor to open when using `--message` to allow the message to    /// be edited afterward.    #[arg(long)]    editor: bool,     /// Split the revision into two parallel revisions instead of a parent and    /// child    #[arg(long, short)]    parallel: bool,     /// Files matching any of these filesets are put in the selected changes    #[arg(value_name = "FILESETS", value_hint = clap::ValueHint::AnyPath)]    #[arg(add = ArgValueCompleter::new(complete::modified_revision_files))]    paths: Vec<String>,} impl SplitArgs {    /// Resolves the raw SplitArgs into the components necessary to run the    /// command. Returns an error if the command cannot proceed.    async fn resolve(        &self,        ui: &Ui,        workspace_command: &WorkspaceCommandHelper,    ) -> Result<ResolvedSplitArgs, CommandError> {        let target_commit = workspace_command            .resolve_single_rev(ui, &self.revision)            .await?;        workspace_command            .check_rewritable([target_commit.id()])            .await?;        let repo = workspace_command.repo();        let fileset_expression = workspace_command.parse_file_patterns(ui, &self.paths)?;        let matcher = fileset_expression.to_matcher();        let diff_selector = workspace_command.diff_selector(            ui,            self.tool.as_deref(),            self.interactive || self.paths.is_empty(),        )?;        let use_move_flags =            self.onto.is_some() || self.insert_after.is_some() || self.insert_before.is_some();        let (new_parent_ids, new_child_ids) = if use_move_flags {            compute_commit_location(                ui,                workspace_command,                self.onto.as_deref(),                self.insert_after.as_deref(),                self.insert_before.as_deref(),                "split-out commit",            )            .await?        } else {            Default::default()        };         print_unmatched_explicit_paths(            ui,            workspace_command,            &fileset_expression,            [                // We check the parent commit to account for deleted files.                &target_commit.parent_tree(repo.as_ref()).await?,                &target_commit.tree(),            ],        )        .await?;         Ok(ResolvedSplitArgs {            target_commit,            matcher,            diff_selector,            parallel: self.parallel,            use_move_flags,            new_parent_ids,            new_child_ids,        })    }} struct ResolvedSplitArgs {    target_commit: Commit,    matcher: Box<dyn Matcher>,    diff_selector: DiffSelector,    parallel: bool,    use_move_flags: bool,    new_parent_ids: Vec<CommitId>,    new_child_ids: Vec<CommitId>,} #[instrument(skip_all)]pub(crate) async fn cmd_split(    ui: &mut Ui,    command: &CommandHelper,    args: &SplitArgs,) -> Result<(), CommandError> {    let mut workspace_command = command.workspace_helper(ui).await?;    let ResolvedSplitArgs {        target_commit,        matcher,        diff_selector,        parallel,        use_move_flags,        new_parent_ids,        new_child_ids,    } = args.resolve(ui, &workspace_command).await?;    let text_editor = workspace_command.text_editor()?;    let mut tx = workspace_command.start_transaction();     // Prompt the user to select the changes they want for the first commit.    let target = select_diff(ui, &tx, &target_commit, &matcher, &diff_selector).await?;     // Create the first commit, which includes the changes selected by the user.    let first_commit = {        let mut commit_builder = tx.repo_mut().rewrite_commit(&target.commit).detach();        commit_builder.set_tree(target.selected_tree.clone());        if use_move_flags {            commit_builder.clear_rewrite_source();            // Generate a new change id so that the commit being split doesn't            // become divergent.            commit_builder.generate_new_change_id();        }        let use_editor = args.message_paragraphs.is_none() || args.editor;        let description = match &args.message_paragraphs {            Some(paragraphs) => join_message_paragraphs(paragraphs),            None => commit_builder.description().to_owned(),        };        // The first trailer would become the first line of the description.        // Also, a commit with no description is treated in a special way in        // jujutsu: it can be discarded as soon as it's no longer the working        // copy. Adding a trailer to an empty description would break that        // logic.        let description = if !description.is_empty() || use_editor {            commit_builder.set_description(description);            add_trailers(ui, &tx, &commit_builder).await?        } else {            description        };        let description = if use_editor {            commit_builder.set_description(description);            let temp_commit = commit_builder.write_hidden().await?;            let intro = "Enter a description for the selected changes.";            let template = description_template(ui, &tx, intro, &temp_commit)?;            edit_description(&text_editor, &template)?        } else {            description        };        commit_builder.set_description(description);        commit_builder.write(tx.repo_mut()).await?    };     // Create the second commit, which includes everything the user didn't    // select.    let second_commit = {        let target_tree = target.commit.tree();        let new_tree = if parallel {            // Merge the original commit tree with its parent using the tree            // containing the user selected changes as the base for the merge.            // This results in a tree with the changes the user didn't select.            let selected_diff = target                .diff_with_labels(                    "parents of split revision",                    "selected changes for split",                    "split revision",                )                .await?;            MergedTree::merge(Merge::from_diffs(                (                    target_tree,                    format!("split revision ({})", target.commit.conflict_label()),                ),                [selected_diff.invert()],            ))            .await?        } else {            target_tree        };        let parents = if parallel {            target.commit.parent_ids().to_vec()        } else {            vec![first_commit.id().clone()]        };        let mut commit_builder = tx.repo_mut().rewrite_commit(&target.commit).detach();        commit_builder.set_parents(parents).set_tree(new_tree);        let mut show_editor = args.editor;        if !use_move_flags {            commit_builder.clear_rewrite_source();            // Generate a new change id so that the commit being split doesn't            // become divergent.            commit_builder.generate_new_change_id();        }        let description = if target.commit.description().is_empty() {            // If there was no description before, don't ask for one for the            // second commit.            "".to_string()        } else {            show_editor = show_editor || args.message_paragraphs.is_none();            // Just keep the original message unchanged            commit_builder.description().to_owned()        };        let description = if show_editor {            let new_description = add_trailers(ui, &tx, &commit_builder).await?;            commit_builder.set_description(new_description);            let temp_commit = commit_builder.write_hidden().await?;            let intro = "Enter a description for the remaining changes.";            let template = description_template(ui, &tx, intro, &temp_commit)?;            edit_description(&text_editor, &template)?        } else {            description        };        commit_builder.set_description(description);        commit_builder.write(tx.repo_mut()).await?    };     let (first_commit, second_commit, num_rebased) = if use_move_flags {        move_first_commit(            &mut tx,            &target,            first_commit,            second_commit,            new_parent_ids,            new_child_ids,        )        .await?    } else {        rewrite_descendants(&mut tx, &target, first_commit, second_commit, parallel).await?    };    if let Some(mut formatter) = ui.status_formatter() {        if num_rebased > 0 {            writeln!(formatter, "Rebased {num_rebased} descendant commits.")?;        }        write!(formatter, "Selected changes : ")?;        tx.write_commit_summary(formatter.as_mut(), &first_commit)?;        write!(formatter, "\nRemaining changes: ")?;        tx.write_commit_summary(formatter.as_mut(), &second_commit)?;        writeln!(formatter)?;    }    tx.finish(ui, format!("split commit {}", target.commit.id().hex()))        .await?;    Ok(())} async fn move_first_commit(    tx: &mut WorkspaceCommandTransaction<'_>,    target: &CommitWithSelection,    mut first_commit: Commit,    mut second_commit: Commit,    new_parent_ids: Vec<CommitId>,    new_child_ids: Vec<CommitId>,) -> Result<(Commit, Commit, usize), CommandError> {    let mut rewritten_commits: HashMap<CommitId, CommitId> = HashMap::new();    rewritten_commits.insert(target.commit.id().clone(), second_commit.id().clone());    tx.repo_mut()        .transform_descendants(            vec![target.commit.id().clone()],            async |rewriter: CommitRewriter<'_>| {                let old_commit_id = rewriter.old_commit().id().clone();                let new_commit = rewriter.rebase().await?.write().await?;                rewritten_commits.insert(old_commit_id, new_commit.id().clone());                Ok(())            },        )        .await?;     let new_parent_ids: Vec<_> = new_parent_ids        .iter()        .map(|commit_id| rewritten_commits.get(commit_id).unwrap_or(commit_id))        .cloned()        .collect();    let new_child_ids: Vec<_> = new_child_ids        .iter()        .map(|commit_id| rewritten_commits.get(commit_id).unwrap_or(commit_id))        .cloned()        .collect();    let stats = move_commits(        tx.repo_mut(),        &MoveCommitsLocation {            new_parent_ids,            new_child_ids,            target: MoveCommitsTarget::Commits(vec![first_commit.id().clone()]),        },        &RebaseOptions {            empty: EmptyBehavior::Keep,            rewrite_refs: RewriteRefsOptions {                delete_abandoned_bookmarks: false,            },            simplify_ancestor_merge: false,        },    )    .await?;     // 1 for the transformation of the original commit to the second commit    // that was inserted in rewritten_commits    let mut num_new_rebased = 1;    if let Some(RebasedCommit::Rewritten(commit)) = stats.rebased_commits.get(first_commit.id()) {        first_commit = commit.clone();        num_new_rebased += 1;    }    if let Some(RebasedCommit::Rewritten(commit)) = stats.rebased_commits.get(second_commit.id()) {        second_commit = commit.clone();    }     let num_rebased = rewritten_commits.len() + stats.rebased_commits.len()        // don't count the commit generated by the split in the rebased commits        - num_new_rebased        // only count once a commit that may have been rewritten twice in the process        - rewritten_commits            .iter()            .filter(|(_, rewritten)| stats.rebased_commits.contains_key(rewritten))            .count();     Ok((first_commit, second_commit, num_rebased))} async fn rewrite_descendants(    tx: &mut WorkspaceCommandTransaction<'_>,    target: &CommitWithSelection,

Lines 1–500 of 601.