Skip to main content

jj_cli/
movement_util.rs

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.
14
15use std::io::Write as _;
16use std::sync::Arc;
17
18use futures::TryStreamExt as _;
19use jj_lib::backend::CommitId;
20use jj_lib::commit::Commit;
21use jj_lib::repo::Repo as _;
22use jj_lib::revset::ResolvedRevsetExpression;
23use jj_lib::revset::RevsetExpression;
24use jj_lib::revset::RevsetFilterPredicate;
25use jj_lib::revset::RevsetStreamExt as _;
26
27use crate::cli_util::CommandHelper;
28use crate::cli_util::WorkspaceCommandHelper;
29use crate::cli_util::short_commit_hash;
30use crate::command_error::CommandError;
31use crate::command_error::user_error;
32use crate::ui::Ui;
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub(crate) struct MovementArgs {
36    pub offset: u64,
37    pub edit: bool,
38    pub no_edit: bool,
39    pub conflict: bool,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43struct MovementArgsInternal {
44    offset: u64,
45    should_edit: bool,
46    conflict: bool,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub(crate) enum Direction {
51    Next,
52    Prev,
53}
54
55impl Direction {
56    fn cmd(&self) -> &'static str {
57        match self {
58            Self::Next => "next",
59            Self::Prev => "prev",
60        }
61    }
62
63    fn target_not_found_error(
64        &self,
65        workspace_command: &WorkspaceCommandHelper,
66        args: &MovementArgsInternal,
67        commits: &[Commit],
68    ) -> CommandError {
69        let offset = args.offset;
70        let err_msg = match (self, args.should_edit, args.conflict) {
71            // in edit mode, start_revset is the WC, so we only look for direct descendants.
72            (Self::Next, true, true) => {
73                String::from("The working copy has no descendants with conflicts")
74            }
75            (Self::Next, true, false) => {
76                format!("No descendant found {offset} commit(s) forward from the working copy",)
77            }
78            // in non-edit mode, start_revset is the parent of WC, so we look for other descendants
79            // of start_revset.
80            (Self::Next, false, true) => {
81                String::from("The working copy parent(s) have no other descendants with conflicts")
82            }
83            (Self::Next, false, false) => format!(
84                "No other descendant found {offset} commit(s) forward from the working copy \
85                 parent(s)",
86            ),
87            // The WC can never be an ancestor of the start_revset since start_revset is either
88            // itself or it's parent.
89            (Self::Prev, true, true) => {
90                String::from("The working copy has no ancestors with conflicts")
91            }
92            (Self::Prev, true, false) => {
93                format!("No ancestor found {offset} commit(s) back from the working copy",)
94            }
95            (Self::Prev, false, true) => {
96                String::from("The working copy parent(s) have no ancestors with conflicts")
97            }
98            (Self::Prev, false, false) => format!(
99                "No ancestor found {offset} commit(s) back from the working copy parents(s)",
100            ),
101        };
102
103        let template = workspace_command.commit_summary_template();
104        let mut cmd_err = user_error(err_msg);
105        for commit in commits {
106            cmd_err.add_formatted_hint_with(|formatter| {
107                if args.should_edit {
108                    write!(formatter, "Working copy: ")?;
109                } else {
110                    write!(formatter, "Working copy parent: ")?;
111                }
112                template.format(commit, formatter)
113            });
114        }
115
116        cmd_err
117    }
118
119    fn build_target_revset(
120        &self,
121        working_revset: &Arc<ResolvedRevsetExpression>,
122        start_revset: &Arc<ResolvedRevsetExpression>,
123        args: &MovementArgsInternal,
124    ) -> Result<Arc<ResolvedRevsetExpression>, CommandError> {
125        let nth = match (self, args.should_edit) {
126            (Self::Next, true) => start_revset.descendants_at(args.offset),
127            (Self::Next, false) => start_revset
128                .children()
129                .minus(working_revset)
130                .descendants_at(args.offset - 1),
131            (Self::Prev, _) => start_revset.ancestors_at(args.offset),
132        };
133
134        let target_revset = match (self, args.conflict) {
135            (_, false) => nth,
136            (Self::Next, true) => nth
137                .descendants()
138                .filtered(RevsetFilterPredicate::HasConflict)
139                .roots(),
140            // If people desire to move to the root conflict, replace the `heads()` below
141            // with `roots(). But let's wait for feedback.
142            (Self::Prev, true) => nth
143                .ancestors()
144                .filtered(RevsetFilterPredicate::HasConflict)
145                .heads(),
146        };
147
148        Ok(target_revset)
149    }
150}
151
152async fn get_target_commit(
153    ui: &mut Ui,
154    workspace_command: &WorkspaceCommandHelper,
155    direction: Direction,
156    working_commit_id: &CommitId,
157    args: &MovementArgsInternal,
158) -> Result<Commit, CommandError> {
159    let wc_revset = RevsetExpression::commit(working_commit_id.clone());
160
161    // If we're not editing, the working-copy shouldn't have any children
162    if !args.should_edit
163        && !workspace_command
164            .repo()
165            .view()
166            .heads()
167            .contains(working_commit_id)
168    {
169        return Err(user_error("The working copy must not have any children")
170            .hinted("Create a new commit on top of this one or use `--edit`"));
171    }
172
173    // If we're editing, start at the working-copy commit. Otherwise, start from
174    // its direct parent(s).
175    let start_revset = if args.should_edit {
176        wc_revset.clone()
177    } else {
178        wc_revset.parents()
179    };
180
181    let target_revset = direction.build_target_revset(&wc_revset, &start_revset, args)?;
182
183    let targets: Vec<Commit> = target_revset
184        .evaluate(workspace_command.repo().as_ref())?
185        .stream()
186        .commits(workspace_command.repo().store())
187        .try_collect()
188        .await?;
189
190    let target = match targets.as_slice() {
191        [target] => target,
192        [] => {
193            // We found no ancestor/descendant.
194            let start_commits: Vec<Commit> = start_revset
195                .evaluate(workspace_command.repo().as_ref())?
196                .stream()
197                .commits(workspace_command.repo().store())
198                .try_collect()
199                .await?;
200            return Err(direction.target_not_found_error(workspace_command, args, &start_commits));
201        }
202        commits => choose_commit(ui, workspace_command, direction, commits)?,
203    };
204
205    Ok(target.clone())
206}
207
208fn choose_commit<'a>(
209    ui: &Ui,
210    workspace_command: &WorkspaceCommandHelper,
211    direction: Direction,
212    commits: &'a [Commit],
213) -> Result<&'a Commit, CommandError> {
214    writeln!(
215        ui.stderr(),
216        "ambiguous {} commit, choose one to target:",
217        direction.cmd()
218    )?;
219    let mut formatter = ui.stderr_formatter();
220    let template = workspace_command.commit_summary_template();
221    let mut choices: Vec<String> = Default::default();
222    for (i, commit) in commits.iter().enumerate() {
223        write!(formatter, "{}: ", i + 1)?;
224        template.format(commit, formatter.as_mut())?;
225        writeln!(formatter)?;
226        choices.push(format!("{}", i + 1));
227    }
228    writeln!(formatter, "q: quit the prompt")?;
229    choices.push("q".to_string());
230    drop(formatter);
231
232    let index = ui.prompt_choice(
233        "enter the index of the commit you want to target",
234        &choices,
235        None,
236    )?;
237    commits
238        .get(index)
239        .ok_or_else(|| user_error("ambiguous target commit"))
240}
241
242pub(crate) async fn move_to_commit(
243    ui: &mut Ui,
244    command: &CommandHelper,
245    direction: Direction,
246    args: &MovementArgs,
247) -> Result<(), CommandError> {
248    let mut workspace_command = command.workspace_helper(ui)?;
249
250    let current_wc_id = workspace_command
251        .get_wc_commit_id()
252        .ok_or_else(|| user_error("This command requires a working copy"))?;
253
254    let config_edit_flag = workspace_command.settings().get_bool("ui.movement.edit")?;
255    let args = MovementArgsInternal {
256        should_edit: args.edit || (!args.no_edit && config_edit_flag),
257        offset: args.offset,
258        conflict: args.conflict,
259    };
260
261    let target = get_target_commit(ui, &workspace_command, direction, current_wc_id, &args).await?;
262    let current_short = short_commit_hash(current_wc_id);
263    let target_short = short_commit_hash(target.id());
264    let cmd = direction.cmd();
265    // We're editing, just move to the target commit.
266    if args.should_edit {
267        // We're editing, the target must be rewritable.
268        workspace_command.check_rewritable([target.id()]).await?;
269        let mut tx = workspace_command.start_transaction();
270        tx.edit(&target)?;
271        tx.finish(
272            ui,
273            format!("{cmd}: {current_short} -> editing {target_short}"),
274        )
275        .await?;
276        return Ok(());
277    }
278    let mut tx = workspace_command.start_transaction();
279    // Move the working-copy commit to the new parent.
280    tx.check_out(&target)?;
281    tx.finish(ui, format!("{cmd}: {current_short} -> {target_short}"))
282        .await?;
283    Ok(())
284}