// 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, ©_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 " ); }}
// 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,
©_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
"
);
}
}