429 lines · 14.4 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::BTreeMap; use futures::TryStreamExt as _;use itertools::Itertools as _;use jj_lib::commit::Commit;use jj_lib::copies::CopyRecords;use jj_lib::matchers::Matcher;use jj_lib::merge::Diff;use jj_lib::merged_tree::MergedTree;use jj_lib::repo::Repo;use jj_lib::repo_path::RepoPath;use jj_lib::repo_path::RepoPathBuf;use jj_lib::revset::RevsetExpression;use jj_lib::revset::RevsetFilterPredicate;use jj_lib::working_copy::SnapshotStats;use jj_lib::working_copy::UntrackedReason;use tracing::instrument; use crate::cli_util::CommandHelper;use crate::cli_util::print_conflicted_paths;use crate::cli_util::print_snapshot_stats;use crate::cli_util::print_unmatched_explicit_paths;use crate::command_error::CommandError;use crate::diff_util::DiffFormat;use crate::diff_util::get_copy_records;use crate::formatter::FormatterExt as _;use crate::ui::Ui; /// Show high-level repo status [default alias: st]////// This includes:////// * The working copy commit and its parents, and a summary of the changes in///   the working copy (compared to the merged parents)////// * Conflicts in the working copy////// * [Conflicted bookmarks]////// Note: You can use `jj diff --summary -r <rev>` to see the changed files for/// a specific revision.////// [Conflicted bookmarks]:///     https://docs.jj-vcs.dev/latest/bookmarks/#conflicts#[derive(clap::Args, Clone, Debug)]pub(crate) struct StatusArgs {    /// Restrict the status display to these paths    #[arg(value_name = "FILESETS", value_hint = clap::ValueHint::AnyPath)]    paths: Vec<String>,} #[instrument(skip_all)]pub(crate) async fn cmd_status(    ui: &mut Ui,    command: &CommandHelper,    args: &StatusArgs,) -> Result<(), CommandError> {    let (workspace_command, snapshot_stats, _) = command.workspace_helper_with_stats(ui).await?;    print_snapshot_stats(        ui,        &snapshot_stats,        workspace_command.env().path_converter(),    )?;    let repo = workspace_command.repo();    let maybe_wc_commit = workspace_command        .get_wc_commit_id()        .map(|id| repo.store().get_commit(id))        .transpose()?;    let fileset_expression = workspace_command.parse_file_patterns(ui, &args.paths)?;    let matcher = fileset_expression.to_matcher();    ui.request_pager();    let mut formatter = ui.stdout_formatter();    let formatter = formatter.as_mut();     if let Some(wc_commit) = &maybe_wc_commit {        let status = collect_working_copy_status(repo.as_ref(), wc_commit, snapshot_stats).await?;        print_unmatched_explicit_paths(            ui,            &workspace_command,            &fileset_expression,            [&status.tree],        )?;         if !status.has_any_tracked_changes() && !status.has_any_untracked_paths() {            writeln!(formatter, "The working copy has no changes.")?;        } else {            if status.has_any_tracked_changes() {                let mut copy_records = CopyRecords::default();                for parent in &status.parents {                    let records =                        get_copy_records(repo.store(), parent.id(), status.commit.id(), &matcher)                            .await?;                    copy_records.add_records(records);                }                let diff_renderer = workspace_command.diff_renderer(vec![DiffFormat::Summary]);                let width = ui.term_width();                let mut diff_output = vec![];                diff_renderer                    .show_diff(                        ui,                        ui.new_formatter(&mut diff_output).as_mut(),                        Diff::new(&status.parent_tree, &status.tree),                        &matcher,                        &copy_records,                        width,                    )                    .await?;                if !diff_output.is_empty() {                    writeln!(formatter, "Working copy changes:")?;                    formatter.raw()?.write_all(&diff_output)?;                }            }             let mut matching_untracked_paths = status.untracked_paths_matching(&matcher).peekable();            if matching_untracked_paths.peek().is_some() {                writeln!(formatter, "Untracked paths:")?;                visit_collapsed_untracked_files(                    matching_untracked_paths,                    status.tree.clone(),                    |path, is_dir| {                        let ui_path = workspace_command.path_converter().format_file_path(path);                        writeln!(                            formatter.labeled("diff").labeled("untracked"),                            "? {ui_path}{}",                            if is_dir {                                std::path::MAIN_SEPARATOR_STR                            } else {                                ""                            }                        )?;                        Ok(())                    },                )                .await?;            }        }         let template = workspace_command.commit_summary_template();        write!(formatter, "Working copy  (@) : ")?;        template.format(&status.commit, formatter)?;        writeln!(formatter)?;        for parent in &status.parents {            //                "Working copy  (@) : "            write!(formatter, "Parent commit (@-): ")?;            template.format(parent, formatter)?;            writeln!(formatter)?;        }         if status.commit.has_conflict() {            let conflicts = status.tree.conflicts_matching(&matcher).collect_vec();            writeln!(                formatter.labeled("warning").with_heading("Warning: "),                "There are unresolved conflicts at these paths:"            )?;            print_conflicted_paths(conflicts, formatter, &workspace_command)?;             let wc_revset = RevsetExpression::commit(status.commit.id().clone());             // Ancestors with conflicts, excluding the current working copy commit.            let ancestors_conflicts: Vec<_> = workspace_command                .attach_revset_evaluator(                    wc_revset                        .parents()                        .ancestors()                        .filtered(RevsetFilterPredicate::HasConflict)                        .minus(&workspace_command.env().immutable_expression()),                )                .evaluate_to_commit_ids()?                .try_collect()                .await?;             workspace_command                .report_repo_conflicts(formatter, repo, ancestors_conflicts)                .await?;        } else {            for parent in &status.parents {                if parent.has_conflict() {                    writeln!(                        formatter.labeled("hint").with_heading("Hint: "),                        "Conflict in parent commit has been resolved in working copy."                    )?;                    break;                }            }        }    } else {        writeln!(formatter, "No working copy.")?;    }     let conflicted_local_bookmarks = repo        .view()        .local_bookmarks()        .filter(|(_, target)| target.has_conflict())        .map(|(bookmark_name, _)| bookmark_name)        .collect_vec();    let conflicted_remote_bookmarks = repo        .view()        .all_remote_bookmarks()        .filter(|(_, remote_ref)| remote_ref.target.has_conflict())        .map(|(symbol, _)| symbol)        .collect_vec();    if !conflicted_local_bookmarks.is_empty() {        writeln!(            formatter.labeled("warning").with_heading("Warning: "),            "These bookmarks have conflicts:"        )?;        for name in conflicted_local_bookmarks {            write!(formatter, "  ")?;            write!(formatter.labeled("bookmark"), "{}", name.as_symbol())?;            writeln!(formatter)?;        }        writeln!(            formatter.labeled("hint").with_heading("Hint: "),            "Use `jj bookmark list` to see details. Use `jj bookmark set <name> -r <rev>` to \             resolve."        )?;    }    if !conflicted_remote_bookmarks.is_empty() {        writeln!(            formatter.labeled("warning").with_heading("Warning: "),            "These remote bookmarks have conflicts:"        )?;        for symbol in conflicted_remote_bookmarks {            write!(formatter, "  ")?;            write!(formatter.labeled("bookmark"), "{symbol}")?;            writeln!(formatter)?;        }        writeln!(            formatter.labeled("hint").with_heading("Hint: "),            "Use `jj bookmark list` to see details. Resolve by fetching an updated bookmark from \             the remote."        )?;    }     Ok(())} struct WorkingCopyStatus {    commit: Commit,    parents: Vec<Commit>,    parent_tree: MergedTree,    tree: MergedTree,    untracked_paths: BTreeMap<RepoPathBuf, UntrackedReason>,} impl WorkingCopyStatus {    fn has_any_tracked_changes(&self) -> bool {        self.tree.tree_ids() != self.parent_tree.tree_ids()    }     fn has_any_untracked_paths(&self) -> bool {        !self.untracked_paths.is_empty()    }     fn untracked_paths_matching(&self, matcher: &dyn Matcher) -> impl Iterator<Item = &RepoPath> {        self.untracked_paths            .keys()            .filter(|path| matcher.matches(path))            .map(|path| path.as_ref())    }} async fn collect_working_copy_status(    repo: &dyn Repo,    commit: &Commit,    snapshot_stats: SnapshotStats,) -> Result<WorkingCopyStatus, CommandError> {    let commit = commit.clone();    let parents = commit.parents().await?;    let parent_tree = commit.parent_tree(repo).await?;    let tree = commit.tree();    let untracked_paths = snapshot_stats.untracked_paths;     Ok(WorkingCopyStatus {        commit,        parents,        parent_tree,        tree,        untracked_paths,    })} async fn visit_collapsed_untracked_files(    untracked_paths: impl IntoIterator<Item = impl AsRef<RepoPath>>,    tree: MergedTree,    mut on_path: impl FnMut(&RepoPath, bool) -> Result<(), CommandError>,) -> Result<(), CommandError> {    let trees = tree.trees().await?;    let mut stack = vec![trees];     // TODO: This loop can be improved with BTreeMap cursors once that's stable,    // would remove the need for the whole `skip_prefixed_by` thing and turn it    // into a B-tree lookup.    let mut skip_prefixed_by_dir: Option<RepoPathBuf> = None;    'untracked: for path in untracked_paths {        let path = path.as_ref();        if skip_prefixed_by_dir            .as_ref()            .is_some_and(|p| path.starts_with(p))        {            continue;        } else {            skip_prefixed_by_dir = None;        }         let mut it = path.components().dropping_back(1);        let first_mismatch = it.by_ref().enumerate().find(|(i, component)| {            stack.get(i + 1).is_none_or(|tree| {                tree.dir()                    .components()                    .next_back()                    .expect("should always have at least one element (the root)")                    != *component            })        });         if let Some((i, component)) = first_mismatch {            stack.truncate(i + 1);            for component in std::iter::once(component).chain(it) {                let parent = stack                    .last()                    .expect("should always have at least one element (the root)");                 if let Some(subtree) = parent.sub_tree(component).await? {                    stack.push(subtree);                } else {                    let dir = parent.dir().join(component);                     on_path(&dir, true)?;                    skip_prefixed_by_dir = Some(dir);                     continue 'untracked;                }            }        }         on_path(path, false)?;    }     Ok(())} #[cfg(test)]mod test {    use pollster::FutureExt as _;    use testutils::TestRepo;    use testutils::TestTreeBuilder;    use testutils::repo_path;     use super::*;     fn collect_collapsed_untracked_files_string(        untracked_paths: &[&RepoPath],        tree: MergedTree,    ) -> String {        let mut result = String::new();        visit_collapsed_untracked_files(untracked_paths, tree, |path, is_dir| {            result.push_str("? ");            if is_dir {                result.push_str(&path.to_internal_dir_string());            } else {                result.push_str(path.as_internal_file_string());            }            result.push('\n');            Ok(())        })        .block_on()        .unwrap();        result    }     #[test]    fn test_collapsed_untracked_files() {        let repo = TestRepo::init();         let tracked = {            let mut builder = TestTreeBuilder::new(repo.repo.store().clone());             builder.file(repo_path("top_level_file"), "");            // ? "untracked_top_level_file"            // ? "dir"            // ? "dir2/c"            builder.file(repo_path("dir2/d"), "");            // ? "dir3/partially_tracked/e"            builder.file(repo_path("dir3/partially_tracked/f"), "");            // ? "dir3/fully_untracked/"            builder.file(repo_path("dir3/j"), "");            // ? "dir3/k"             builder.write_merged_tree()        };        let untracked = &[            repo_path("untracked_top_level_file"),            repo_path("dir/a"),            repo_path("dir/b"),            repo_path("dir2/c"),            repo_path("dir3/partially_tracked/e"),            repo_path("dir3/fully_untracked/g"),            repo_path("dir3/fully_untracked/h"),            repo_path("dir3/k"),        ];         insta::assert_snapshot!(            collect_collapsed_untracked_files_string(untracked, tracked),            @"        ? untracked_top_level_file        ? dir/        ? dir2/c        ? dir3/partially_tracked/e        ? dir3/fully_untracked/        ? dir3/k        "        );    }}