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