use std::{
cell::OnceCell,
collections::HashMap,
path::{Path, PathBuf},
rc::Rc,
slice,
sync::Arc,
};
use anyhow::{Context, Error, Result, anyhow};
use chrono::TimeZone;
use futures_util::TryStreamExt;
use itertools::Itertools;
use jj_cli::{
cli_util::{default_ignored_remote_name, short_operation_hash},
git_util::{is_colocated_git_workspace, load_git_import_options},
revset_util,
ui::Ui,
};
use jj_lib::{
backend::{BackendError, ChangeId, CommitId},
commit::Commit,
default_index::DefaultReadonlyIndex,
file_util,
fileset::{self, FilesetAliasesMap, FilesetDiagnostics, FilesetParseContext},
git::{self, GitSettings, REMOTE_NAME_FOR_LOCAL_GIT_REPO},
git_backend::GitBackend,
gitignore::GitIgnoreFile,
id_prefix::{IdPrefixContext, IdPrefixIndex},
matchers::{Matcher, NothingMatcher},
object_id::ObjectId,
op_heads_store,
operation::Operation,
ref_name::{WorkspaceName, WorkspaceNameBuf},
repo::{ReadonlyRepo, Repo, RepoLoaderError, StoreFactories},
repo_path::{RepoPath, RepoPathUiConverter},
revset::{
self, Revset, RevsetAliasesMap, RevsetDiagnostics, RevsetEvaluationError, RevsetExpression,
RevsetExtensions, RevsetParseContext, RevsetResolutionError, RevsetStreamExt,
RevsetWorkspaceContext, SymbolResolverExtension, UserRevsetExpression,
},
rewrite,
settings::{HumanByteSize, UserSettings},
transaction::Transaction,
view::View,
working_copy::{CheckoutStats, SnapshotOptions, WorkingCopyFreshness},
workspace::{self, DefaultWorkspaceLoaderFactory, Workspace, WorkspaceLoaderFactory},
workspace_store::{SimpleWorkspaceStore, WorkspaceStore as _},
};
use pollster::FutureExt as _;
use thiserror::Error;
use super::{WorkerSession, git_util::get_git_remote_names};
use crate::{
config::{GGSettings, read_config},
messages::{self, *},
};
pub struct WorkspaceSession<'a> {
pub session: &'a mut WorkerSession,
pub(crate) workspace: Workspace,
pub(crate) data: WorkspaceData,
is_large: bool, is_colocated: bool,
operation: OperationData,
}
pub(crate) struct WorkspaceData {
pub path_converter: RepoPathUiConverter,
extensions: RevsetExtensions,
pub workspace_settings: UserSettings,
pub aliases_map: RevsetAliasesMap,
pub fileset_aliases_map: FilesetAliasesMap,
pub query_choices: HashMap<String, String>,
}
pub(crate) struct OperationData {
pub repo: Arc<ReadonlyRepo>,
pub wc_id: CommitId,
ref_index: OnceCell<Rc<RefIndex>>,
workspace_index: OnceCell<HashMap<CommitId, String>>,
prefix_context: IdPrefixContext,
}
#[derive(Debug, Error)]
pub(crate) enum RevsetError {
#[error(transparent)]
Resolution(#[from] RevsetResolutionError),
#[error(transparent)]
Evaluation(#[from] RevsetEvaluationError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl From<BackendError> for RevsetError {
fn from(value: BackendError) -> Self {
RevsetError::Other(anyhow!(value))
}
}
impl WorkerSession {
pub async fn load_workspace(&mut self, cwd: &Path) -> Result<WorkspaceSession<'_>> {
let factory = DefaultWorkspaceLoaderFactory;
let loader = factory.create(find_workspace_dir(cwd))?;
let (settings, aliases_map, fileset_aliases_map, preset_choices) =
read_config(Some(loader.repo_path()))?;
let workspace = loader.load(
&settings,
&StoreFactories::default(),
&workspace::default_working_copy_factories(),
)?;
let path_converter = RepoPathUiConverter::Fs {
cwd: workspace.workspace_root().to_owned(),
base: workspace.workspace_root().to_owned(),
};
let data: WorkspaceData = WorkspaceData {
workspace_settings: settings,
path_converter,
aliases_map,
fileset_aliases_map,
extensions: Default::default(),
query_choices: preset_choices,
};
let operation = load_at_head(&workspace, &data).await?;
let index_store = workspace.repo_loader().index_store();
let index = index_store
.get_index_at_op(operation.repo.operation(), workspace.repo_loader().store())
.await?;
let is_large = if let Some(default_index) = index.downcast_ref::<DefaultReadonlyIndex>() {
let stats = default_index.stats();
stats.num_commits as i64 >= data.workspace_settings.query_large_repo_heuristic()
} else {
true
};
let is_colocated = is_colocated_git_workspace(&workspace, &operation.repo);
Ok(WorkspaceSession {
session: self,
workspace,
data,
is_large,
is_colocated,
operation,
})
}
}
impl WorkspaceSession<'_> {
pub fn name(&self) -> &WorkspaceName {
self.workspace.workspace_name()
}
pub fn wc_id(&self) -> &CommitId {
&self.operation.wc_id
}
pub async fn add_workspace(&mut self, name: String, path: PathBuf) -> Result<()> {
let workspace_name: WorkspaceNameBuf = name.into();
anyhow::ensure!(
!workspace_name.as_str().is_empty(),
"workspace name cannot be empty"
);
anyhow::ensure!(
self.view().get_wc_commit_id(&workspace_name).is_none(),
"workspace '{}' already exists",
workspace_name.as_symbol()
);
if path.exists() {
anyhow::ensure!(
file_util::is_empty_dir(&path)?,
"destination path exists and is not an empty directory"
);
} else {
std::fs::create_dir(&path)
.with_context(|| format!("failed to create directory {}", path.display()))?;
}
self.import_and_snapshot(true, false).await?;
let (mut new_workspace, repo) = Workspace::init_workspace_with_existing_repo(
&path,
self.workspace.repo_path(),
&self.operation.repo,
&*workspace::default_working_copy_factory(),
workspace_name.clone(),
)
.await?;
let mut tx = repo.start_transaction();
let wc_commit = tx.repo().store().get_commit(self.wc_id())?;
let parents = wc_commit.parents().await?;
let parent_ids: Vec<_> = parents.iter().map(|c| c.id().clone()).collect();
let tree = rewrite::merge_commit_trees(tx.repo(), &parents).await?;
let new_wc_commit = tx.repo_mut().new_commit(parent_ids, tree).write().await?;
tx.repo_mut()
.edit(workspace_name.clone(), &new_wc_commit)
.await?;
self.finish_transaction(
tx,
format!(
"create initial working-copy commit in workspace '{}'",
workspace_name.as_symbol()
),
)
.await?;
new_workspace
.check_out(self.operation.repo.op_id().clone(), None, &new_wc_commit)
.await?;
Ok(())
}
pub async fn forget_workspace(&mut self, name: String) -> Result<()> {
let workspace_name: WorkspaceNameBuf = name.into();
anyhow::ensure!(
self.view().get_wc_commit_id(&workspace_name).is_some(),
"workspace '{}' not found",
workspace_name.as_symbol()
);
anyhow::ensure!(
*workspace_name != *self.name(),
"cannot forget the current workspace"
);
let mut tx = self.start_transaction().await?;
tx.repo_mut().remove_wc_commit(&workspace_name).await?;
let workspace_store = SimpleWorkspaceStore::load(self.workspace.repo_path())?;
workspace_store.forget(&[&*workspace_name])?;
self.finish_transaction(
tx,
format!("forget workspace '{}'", workspace_name.as_symbol()),
)
.await?;
Ok(())
}
pub async fn rename_workspace(
&mut self,
old_name: WorkspaceNameBuf,
new_name: WorkspaceNameBuf,
) -> Result<Option<messages::RepoStatus>> {
let mut tx = self.start_transaction().await?;
let repo_path = self.workspace.repo_path().to_owned();
let is_colocated = self.is_colocated;
let mut locked_ws = self.workspace.start_working_copy_mutation().await?;
locked_ws.locked_wc().rename_workspace(new_name.clone());
tx.repo_mut()
.rename_workspace(&old_name, new_name.clone())?;
let workspace_store = SimpleWorkspaceStore::load(&repo_path)?;
workspace_store.rename(&old_name, &new_name)?;
if is_colocated {
git::export_refs(tx.repo_mut())?;
}
self.operation = OperationData::new(
&new_name,
&self.data,
tx.commit(format!(
"rename workspace '{}' to '{}'",
old_name.as_symbol(),
new_name.as_symbol()
))
.await?,
);
locked_ws
.finish(self.operation.repo.op_id().clone())
.await?;
Ok(Some(self.format_status()))
}
pub fn workspace_root(&self, name: String) -> Result<PathBuf> {
let workspace_name: WorkspaceNameBuf = name.into();
anyhow::ensure!(
self.view().get_wc_commit_id(&workspace_name).is_some(),
"Workspace '{}' not found",
workspace_name.as_symbol()
);
if *workspace_name == *self.name() {
return absolute_workspace_path(self.workspace.workspace_root());
}
let repo_path = self.workspace.repo_path();
let workspace_store = SimpleWorkspaceStore::load(repo_path)?;
match workspace_store.get_workspace_path(&workspace_name)? {
Some(stored_path) => absolute_workspace_path(&repo_path.join(stored_path)),
None if *workspace_name == *WorkspaceName::DEFAULT => repo_path
.parent()
.and_then(Path::parent)
.filter(|root| hosts_repo(root, repo_path))
.map(absolute_workspace_path)
.unwrap_or_else(|| Err(anyhow!("Workspace 'default' has no recorded path"))),
None => Err(anyhow!(
"Workspace '{}' has no recorded path",
workspace_name.as_symbol()
)),
}
}
pub fn list_workspaces(&self) -> Vec<String> {
let mut names: Vec<String> = self
.view()
.wc_commit_ids()
.keys()
.map(|name| name.as_symbol().to_string())
.collect();
names.sort();
names
}
pub(crate) fn sink(&self) -> Arc<dyn super::EventSink> {
self.session.sink.clone()
}
pub(crate) fn repo(&self) -> &ReadonlyRepo {
self.operation.repo.as_ref()
}
pub(crate) fn view(&self) -> &View {
self.operation.repo.view()
}
pub(crate) fn get_commit(&self, id: &CommitId) -> Result<Commit> {
Ok(self.operation.repo.store().get_commit(id)?)
}
pub(crate) fn git_repo(&self) -> Option<gix::Repository> {
self.operation
.git_backend()
.map(|backend| backend.git_repo().to_owned())
}
pub(crate) async fn load_at_head(&mut self) -> Result<bool> {
let head = load_at_head(&self.workspace, &self.data).await?;
if head.repo.op_id() != self.operation.repo.op_id() {
self.operation = head;
Ok(true)
} else {
Ok(false)
}
}
pub(crate) fn evaluate_revset_expr<'op>(
&'op self,
repo: &'op dyn Repo,
revset_expr: Arc<UserRevsetExpression>,
) -> Result<Box<dyn Revset + 'op>, RevsetError> {
let resolved_expression = revset_expr.resolve_user_expression(repo, &self.resolver())?;
let revset = resolved_expression.evaluate(repo)?;
Ok(revset)
}
pub(crate) fn evaluate_revset_str<'op>(
&'op self,
revset_str: &str,
) -> Result<Box<dyn Revset + 'op>, RevsetError> {
let revset_expr = parse_revset(&self.parse_context(), revset_str)?;
self.evaluate_revset_expr(self.operation.repo.as_ref(), revset_expr)
}
pub(crate) fn evaluate_revset_commits<'op>(
&'op self,
ids: &[messages::CommitId],
) -> Result<Box<dyn Revset + 'op>, RevsetError> {
let expr = RevsetExpression::commits(
ids.iter()
.map(|id| CommitId::try_from_hex(id.hex.as_str()).expect("frontend-validated id"))
.collect(),
);
self.evaluate_revset_expr(self.operation.repo.as_ref(), expr)
}
pub(crate) fn evaluate_revset_changes<'op>(
&'op self,
ids: &[messages::ChangeId],
) -> Result<Box<dyn Revset + 'op>, RevsetError> {
let mut expr = RevsetExpression::none();
for id in ids.iter() {
expr = expr.union(&RevsetExpression::symbol(id.hex.clone()))
}
self.evaluate_revset_expr(self.operation.repo.as_ref(), expr)
}
pub(crate) fn evaluate_immutable(&self) -> Result<Box<dyn Revset + '_>> {
let mut diagnostics = RevsetDiagnostics::new(); let expr =
revset_util::parse_immutable_heads_expression(&mut diagnostics, &self.parse_context())?;
let expr = expr.ancestors();
let revset = self.evaluate_revset_expr(self.operation.repo.as_ref(), expr)?;
Ok(revset)
}
fn resolve_optional<'op, 'set: 'op, T: AsRef<dyn Revset + 'set>>(
&'op self,
revset: T,
) -> Result<Option<Commit>, RevsetError> {
let store = self.operation.repo.store();
let mut stream = revset.as_ref().stream().commits(store);
let first = stream.try_next().block_on()?;
let second = stream.try_next().block_on()?;
match (first, second) {
(Some(commit), None) => Ok(Some(commit)),
(None, _) => Ok(None),
(Some(_), Some(_)) => Err(RevsetError::Other(anyhow!(
r#"Revset "{:?}" resolved to more than one revision"#,
revset.as_ref()
))),
}
}
fn resolve_single<'op, 'set: 'op, T: AsRef<dyn Revset + 'set>>(
&'op self,
revset: T,
) -> Result<Commit, RevsetError> {
match self.resolve_optional(revset)? {
Some(commit) => Ok(commit),
None => Err(RevsetError::Other(anyhow!(
"Revset didn't resolve to any revisions"
))),
}
}
fn format_id_str(id: &RevId) -> String {
match id.change.offset {
Some(offset) => format!("{}/{}", id.change.hex, offset),
_ => id.change.hex.clone(),
}
}
pub(crate) fn resolve_optional_id(&self, id: &RevId) -> Result<Option<Commit>, RevsetError> {
let id_str = Self::format_id_str(id);
let change_revset = match self.evaluate_revset_str(&id_str) {
Ok(revset) => revset,
Err(RevsetError::Resolution(RevsetResolutionError::NoSuchRevision { .. })) => {
return Ok(None);
}
Err(err) => return Err(err),
};
let store = self.operation.repo.store();
let mut change_stream = change_revset.as_ref().stream().commits(store);
let first = change_stream.try_next().block_on()?;
let second = change_stream.try_next().block_on()?;
match (first, second) {
(Some(commit), None) => Ok(Some(commit)),
(None, _) => Ok(None),
(Some(_), Some(_)) => {
let commit_revset = self.evaluate_revset_commits(slice::from_ref(&id.commit))?;
let mut commit_stream = commit_revset.as_ref().stream().commits(store);
match commit_stream.try_next().block_on()? {
Some(commit) => Ok(Some(commit)),
None => Ok(None),
}
}
}
}
pub(crate) fn resolve_optional_set(
&self,
set: &RevSet,
) -> Result<Option<Vec<Commit>>, RevsetError> {
let revset_str = format!(
"{}::{}",
Self::format_id_str(&set.from),
Self::format_id_str(&set.to)
);
let revset = match self.evaluate_revset_str(&revset_str) {
Ok(revset) => revset,
Err(RevsetError::Resolution(RevsetResolutionError::NoSuchRevision { .. })) => {
return Ok(None);
}
Err(err) => return Err(err),
};
let commits = self.resolve_multiple(revset)?;
if commits.is_empty() {
Ok(None)
} else {
Ok(Some(commits))
}
}
pub(crate) fn resolve_change_id(&self, id: &RevId) -> Result<Commit, RevsetError> {
let id_str = Self::format_id_str(id);
let revset = self.evaluate_revset_str(&id_str)?;
let store = self.operation.repo.store();
let mut stream = revset.as_ref().stream().commits(store);
let first = stream.try_next().block_on()?;
let second = stream.try_next().block_on()?;
let optional_change = match (first, second) {
(Some(commit), None) => Some(commit),
(None, _) => None,
(Some(_), Some(_)) => Some(self.resolve_commit_id(&id.commit)?),
};
match optional_change {
Some(commit) => {
let resolved_id = commit.id();
if resolved_id == self.wc_id() || resolved_id.hex().starts_with(&id.commit.prefix) {
Ok(commit)
} else {
Err(RevsetError::Other(anyhow!(
r#""{}" didn't resolve to the expected commit {}"#,
id.change.prefix,
id.commit.prefix
)))
}
}
None => Err(RevsetError::Other(anyhow!(
r#""{}" didn't resolve to any revisions"#,
id.change.prefix
))),
}
}
pub(crate) fn resolve_change_set(
&self,
set: &RevSet,
check_immutable: bool,
) -> Result<(Vec<Commit>, bool), RevsetError> {
if set.from.change.hex == set.to.change.hex
&& set.from.change.offset == set.to.change.offset
{
let commit = self.resolve_change_id(&set.from)?;
let is_immutable =
check_immutable && self.check_immutable([commit.id().clone()]).unwrap_or(false);
Ok((vec![commit], is_immutable))
} else {
let revset_str = format!(
"{}::{}",
Self::format_id_str(&set.from),
Self::format_id_str(&set.to)
);
let revset = self.evaluate_revset_str(&revset_str)?;
let is_immutable =
check_immutable && self.check_immutable_revset(&*revset).unwrap_or(false);
let commits = self.resolve_multiple(revset)?;
Ok((commits, is_immutable))
}
}
pub(crate) fn resolve_commit_id(&self, id: &messages::CommitId) -> Result<Commit, RevsetError> {
let expr = RevsetExpression::commit(
CommitId::try_from_hex(&id.hex).expect("frontend-validated id"),
);
let revset = self.evaluate_revset_expr(self.operation.repo.as_ref(), expr)?;
self.resolve_single(revset)
}
pub(crate) fn resolve_multiple<'op, 'set: 'op, T: AsRef<dyn Revset + 'set>>(
&'op self,
revset: T,
) -> Result<Vec<Commit>, RevsetError> {
let store = self.operation.repo.store();
let commits: Vec<Commit> = revset
.as_ref()
.stream()
.commits(store)
.try_collect()
.block_on()
.map_err(RevsetError::from)?;
Ok(commits)
}
pub(crate) fn resolve_multiple_commits(
&self,
ids: &[messages::CommitId],
) -> Result<Vec<Commit>, RevsetError> {
let revset = self.evaluate_revset_commits(ids)?;
let commits = self.resolve_multiple(revset)?;
Ok(commits)
}
pub(crate) fn resolve_multiple_changes(
&self,
ids: impl IntoIterator<Item = RevId>,
) -> Result<Vec<Commit>, RevsetError> {
let revset =
self.evaluate_revset_changes(&ids.into_iter().map(|id| id.change).collect_vec())?;
let commits = self.resolve_multiple(revset)?;
Ok(commits)
}
fn parse_context(&self) -> RevsetParseContext<'_> {
self.data
.parse_context(self.workspace.workspace_name(), self.repo().store())
}
fn prefix_index(&self) -> IdPrefixIndex<'_> {
self.operation
.prefix_context
.populate(self.repo())
.expect("prefix context disambiguate_within()")
}
fn resolver(&self) -> revset::SymbolResolver<'_> {
revset::SymbolResolver::new(
self.operation.repo.as_ref(),
&([] as [Box<dyn SymbolResolverExtension>; 0]),
)
.with_id_prefix_context(&self.operation.prefix_context)
}
fn ref_index(&self) -> &Rc<RefIndex> {
self.operation
.ref_index
.get_or_init(|| Rc::new(build_ref_index(self.operation.repo.as_ref())))
}
fn workspace_index(&self) -> &HashMap<CommitId, String> {
self.operation.workspace_index.get_or_init(|| {
self.operation
.repo
.view()
.wc_commit_ids()
.iter()
.filter(|(_, id)| **id != self.operation.wc_id)
.map(|(name, id)| (id.clone(), name.as_symbol().to_string()))
.collect()
})
}
pub(crate) fn format_config(&self) -> Result<messages::RepoConfig> {
let absolute_path = self.workspace.workspace_root().into();
let git_remotes = match self.git_repo() {
Some(repo) => get_git_remote_names(&repo),
None => vec![],
};
let default_revset = self
.data
.workspace_settings
.get_string("revsets.log")
.unwrap_or_default();
let mut query_choices = HashMap::new();
query_choices.insert("default".to_string(), default_revset.clone());
if self.data.query_choices.is_empty() {
query_choices.insert(
"tracked-bookmarks".to_string(),
"@ | ancestors(bookmarks(), 5)".to_string(),
);
query_choices.insert(
"remote-bookmarks".to_string(),
"@ | ancestors(remote_bookmarks(), 5)".to_string(),
);
query_choices.insert("all-revisions".to_string(), "all()".to_string());
} else {
query_choices.extend(self.data.query_choices.clone());
}
let latest_query = self
.session
.latest_query
.as_ref()
.unwrap_or(&default_revset)
.clone();
let has_external_diff_tool =
has_external_tool(&self.data.workspace_settings, "ui.diff-formatter");
let has_external_merge_tool =
has_external_tool(&self.data.workspace_settings, "ui.merge-editor");
Ok(messages::RepoConfig::Workspace {
absolute_path,
git_remotes,
query_choices,
latest_query,
status: self.format_status(),
theme_override: self.data.workspace_settings.ui_theme_override(),
mark_unpushed_bookmarks: self.data.workspace_settings.ui_mark_unpushed_bookmarks(),
track_recent_workspaces: self.data.workspace_settings.ui_track_recent_workspaces(),
ignore_immutable: self.session.ignore_immutable,
has_external_diff_tool,
has_external_merge_tool,
})
}
pub(crate) fn format_status(&self) -> messages::RepoStatus {
messages::RepoStatus {
operation_description: self
.operation
.repo
.operation()
.store_operation()
.metadata
.description
.clone(),
working_copy: self.format_commit_id(&self.operation.wc_id),
}
}
pub(crate) fn format_commit_id(&self, id: &CommitId) -> messages::CommitId {
let prefix_len = self
.prefix_index()
.shortest_commit_prefix_len(self.operation.repo.as_ref(), id)
.unwrap_or(id.hex().len());
let hex = id.hex();
let mut prefix = hex.clone();
let rest = prefix.split_off(prefix_len);
messages::CommitId { hex, prefix, rest }
}
pub(crate) fn format_change_id(
&self,
commit_id: &CommitId,
change_id: &ChangeId,
) -> messages::ChangeId {
let prefix_len = self
.prefix_index()
.shortest_change_prefix_len(self.operation.repo.as_ref(), change_id)
.unwrap_or_else(|_| change_id.reverse_hex().len());
let hex = change_id.reverse_hex();
let prefix = hex[..prefix_len].to_string();
let rest = hex[prefix_len..].to_string();
let offset = self
.repo()
.resolve_change_id(change_id)
.ok()
.flatten()
.and_then(|targets| {
let is_hidden = !targets.has_visible(commit_id);
let is_divergent = targets.is_divergent();
if is_hidden || is_divergent {
targets.find_offset(commit_id)
} else {
None
}
});
let is_divergent = self
.repo()
.resolve_change_id(change_id)
.ok()
.flatten()
.map(|targets| targets.is_divergent())
.unwrap_or(false);
messages::ChangeId {
hex,
prefix,
rest,
offset,
is_divergent,
}
}
pub(crate) fn format_id(&self, commit: &Commit) -> RevId {
RevId {
commit: self.format_commit_id(commit.id()),
change: self.format_change_id(commit.id(), commit.change_id()),
}
}
pub(crate) fn format_header(
&self,
commit: &Commit,
known_immutable: Option<bool>,
) -> Result<RevHeader> {
let index = self.ref_index();
let refs = index.get(commit.id()).to_vec();
let is_immutable = known_immutable
.map(Result::Ok)
.unwrap_or_else(|| self.check_immutable(vec![commit.id().clone()]))?;
Ok(RevHeader {
id: self.format_id(commit),
description: commit.description().into(),
author: commit.author().try_into()?,
has_conflict: commit.has_conflict(),
working_copy_of: self.workspace_index().get(commit.id()).cloned(),
is_working_copy: *commit.id() == self.operation.wc_id
|| self.workspace_index().contains_key(commit.id()),
is_immutable,
refs,
parent_ids: commit
.parent_ids()
.iter()
.map(|commit_id| self.format_commit_id(commit_id))
.collect(),
})
}
pub(crate) fn format_path<T: AsRef<RepoPath>>(
&self,
repo_path: T,
) -> Result<messages::TreePath> {
let base_path = self.workspace.workspace_root();
let relative_path =
file_util::relative_path(base_path, &repo_path.as_ref().to_fs_path(base_path)?);
Ok(messages::TreePath {
repo_path: repo_path.as_ref().as_internal_file_string().to_owned(),
relative_path: relative_path.into(),
})
}
fn check_immutable_with_repo<'r>(
&'r self,
repo: &'r dyn Repo,
ids: impl IntoIterator<Item = CommitId>,
) -> Result<bool> {
let check_revset = RevsetExpression::commits(ids.into_iter().collect());
let mut diagnostics = RevsetDiagnostics::new();
let immutable_heads =
revset_util::parse_immutable_heads_expression(&mut diagnostics, &self.parse_context())?;
let immutable_revset = immutable_heads.ancestors();
let intersection_revset = check_revset.intersection(&immutable_revset);
let immutable_revs = self.evaluate_revset_expr(repo, intersection_revset)?;
Ok(!immutable_revs.is_empty())
}
pub(crate) fn check_immutable(&self, ids: impl IntoIterator<Item = CommitId>) -> Result<bool> {
self.check_immutable_with_repo(self.operation.repo.as_ref(), ids)
}
pub(crate) fn check_immutable_revset(&self, revset: &dyn Revset) -> Result<bool> {
let immutable_revset = self.evaluate_immutable()?;
let contains = immutable_revset.containing_fn();
let mut stream = revset.stream();
while let Some(id) = stream.try_next().block_on()? {
if contains(&id)? {
return Ok(true);
}
}
Ok(false)
}
pub(crate) async fn start_transaction(&mut self) -> Result<Transaction> {
let auto_update_stale = self
.data
.workspace_settings
.get_bool("snapshot.auto-update-stale")
.unwrap_or(false);
self.import_and_snapshot(true, auto_update_stale).await?;
Ok(self.operation.repo.start_transaction())
}
pub(crate) async fn finish_transaction(
&mut self,
tx: Transaction,
description: impl Into<String>,
) -> Result<Option<messages::RepoStatus>> {
self.finish_transaction_for_edit(tx, description, false)
.await
}
pub(crate) async fn finish_transaction_for_edit(
&mut self,
mut tx: Transaction,
description: impl Into<String>,
ignore_immutable: bool,
) -> Result<Option<messages::RepoStatus>> {
if !tx.repo().has_changes() {
return Ok(None);
}
tx.repo_mut().rebase_descendants().await?;
for (name, wc_commit_id) in &tx.repo().view().wc_commit_ids().clone() {
if !ignore_immutable
&& self.check_immutable_with_repo(tx.repo(), [wc_commit_id.clone()])?
{
let wc_commit = tx.repo().store().get_commit(wc_commit_id)?;
tx.repo_mut().check_out(name.clone(), &wc_commit).await?;
log::debug!(
"The working-copy commit in workspace '{name}' became immutable, so a new \
commit has been created on top of it.",
name = name.as_symbol()
);
}
}
let old_repo = tx.base_repo().clone();
let maybe_old_wc_commit = old_repo
.view()
.get_wc_commit_id(self.workspace.workspace_name())
.map(|commit_id| tx.base_repo().store().get_commit(commit_id))
.transpose()?;
let maybe_new_wc_commit = tx
.repo()
.view()
.get_wc_commit_id(self.workspace.workspace_name())
.map(|commit_id| tx.repo().store().get_commit(commit_id))
.transpose()?;
if self.is_colocated {
if let Some(wc_commit) = &maybe_new_wc_commit {
git::reset_head(tx.repo_mut(), wc_commit).await?;
}
git::export_refs(tx.repo_mut())?;
}
self.operation = OperationData::new(self.name(), &self.data, tx.commit(description).await?);
if let Some(new_commit) = &maybe_new_wc_commit {
self.update_working_copy(maybe_old_wc_commit.as_ref(), new_commit)
.await?;
}
Ok(Some(self.format_status()))
}
pub(crate) async fn import_and_snapshot(
&mut self,
force: bool,
auto_update_stale: bool,
) -> Result<bool> {
if !(force
|| self
.data
.workspace_settings
.query_auto_snapshot()
.unwrap_or(!self.is_large))
{
return Ok(false);
}
if self.is_colocated {
self.import_git_head().await?;
}
let updated_working_copy = self.snapshot_working_copy(auto_update_stale).await?;
if self.is_colocated {
self.import_git_refs().await?;
}
Ok(updated_working_copy)
}
async fn snapshot_working_copy(&mut self, auto_update_stale: bool) -> Result<bool> {
let workspace_name = self.workspace.workspace_name().to_owned();
let get_wc_commit = |repo: &ReadonlyRepo| -> Result<Option<_>, _> {
repo.view()
.get_wc_commit_id(&workspace_name)
.map(|id| repo.store().get_commit(id))
.transpose()
};
let repo = self.operation.repo.clone();
let Some(wc_commit) = get_wc_commit(&repo)? else {
return Ok(false); };
let base_ignores = self
.operation
.base_ignores(self.workspace.workspace_root())?;
let mut locked_ws = self.workspace.start_working_copy_mutation().await?;
let old_op_id = locked_ws.locked_wc().old_operation_id().clone();
let (repo, wc_commit) = match WorkingCopyFreshness::check_stale(
locked_ws.locked_wc(),
&wc_commit,
&repo,
)
.await?
{
WorkingCopyFreshness::Fresh => (repo, wc_commit),
WorkingCopyFreshness::Updated(wc_operation) => {
let repo = repo.reload_at(&wc_operation).await?;
let wc_commit = if let Some(wc_commit) = get_wc_commit(&repo)? {
wc_commit
} else {
return Ok(false);
};
(repo, wc_commit)
}
WorkingCopyFreshness::WorkingCopyStale => {
if !auto_update_stale {
return Err(anyhow!(
"The working copy is stale (not updated since operation {}). Run `jj workspace update-stale` to update it.",
short_operation_hash(&old_op_id)
));
}
log::info!(
"Updating stale working copy (last updated at operation {})",
short_operation_hash(&old_op_id)
);
let new_wc_commit = wc_commit;
let old_op = repo.op_store().read_operation(&old_op_id).await?;
let old_view = repo.op_store().read_view(&old_op.view_id).await?;
let old_wc_commit = old_view
.wc_commit_ids
.get(&workspace_name)
.map(|id| repo.store().get_commit(id))
.transpose()?;
let old_tree = old_wc_commit.as_ref().map(|c: &Commit| c.tree());
drop(locked_ws);
self.workspace
.check_out(repo.op_id().clone(), old_tree.as_ref(), &new_wc_commit)
.await?;
locked_ws = self.workspace.start_working_copy_mutation().await?;
(repo, new_wc_commit)
}
WorkingCopyFreshness::SiblingOperation => {
return Err(anyhow!(
"The repo was loaded at operation {}, which seems to be a sibling of the working copy's operation {}",
short_operation_hash(repo.op_id()),
short_operation_hash(&old_op_id)
));
}
};
let HumanByteSize(mut max_new_file_size) = self
.data
.workspace_settings
.get_value_with("snapshot.max-new-file-size", TryInto::try_into)?;
if max_new_file_size == 0 {
max_new_file_size = u64::MAX;
}
let (new_tree_id, _) = locked_ws
.locked_wc()
.snapshot(&SnapshotOptions {
base_ignores,
progress: None,
max_new_file_size,
start_tracking_matcher: self.data.auto_tracking_matcher()?.as_ref(),
force_tracking_matcher: &NothingMatcher,
})
.await?;
let did_anything = new_tree_id.tree_ids() != wc_commit.tree_ids();
if did_anything {
let mut tx = repo.start_transaction();
let mut_repo = tx.repo_mut();
let commit = mut_repo
.rewrite_commit(&wc_commit)
.set_tree(new_tree_id)
.write()
.await?;
mut_repo.set_wc_commit(workspace_name.clone(), commit.id().clone())?;
mut_repo.rebase_descendants().await?;
if self.is_colocated {
git::export_refs(mut_repo)?;
}
self.operation = OperationData::new(
&workspace_name,
&self.data,
tx.commit("snapshot working copy").await?,
);
}
locked_ws
.finish(self.operation.repo.op_id().clone())
.await?;
Ok(did_anything)
}
async fn update_working_copy(
&mut self,
maybe_old_commit: Option<&Commit>,
new_commit: &Commit,
) -> Result<Option<CheckoutStats>> {
let old_tree = maybe_old_commit.map(|commit| commit.tree());
Ok(
if Some(new_commit.tree_ids()) != old_tree.as_ref().map(|t| t.tree_ids()) {
Some(
self.workspace
.check_out(
self.operation.repo.op_id().clone(),
old_tree.as_ref(),
new_commit,
)
.await?,
)
} else {
let locked_ws = self.workspace.start_working_copy_mutation().await?;
locked_ws
.finish(self.operation.repo.op_id().clone())
.await?;
None
},
)
}
async fn import_git_head(&mut self) -> Result<()> {
let mut tx = self.operation.repo.start_transaction();
git::import_head(tx.repo_mut()).await?;
if !tx.repo().has_changes() {
return Ok(());
}
let new_git_head = tx.repo().view().git_head().clone();
if let Some(new_git_head_id) = new_git_head.as_normal() {
let workspace_name = self.workspace.workspace_name().to_owned();
if let Some(old_wc_commit_id) =
self.operation.repo.view().get_wc_commit_id(&workspace_name)
{
let old_wc_commit = tx.repo().store().get_commit(old_wc_commit_id)?;
tx.repo_mut().record_abandoned_commit(&old_wc_commit);
}
let new_git_head_commit = tx.repo().store().get_commit(new_git_head_id)?;
tx.repo_mut()
.check_out(workspace_name.clone(), &new_git_head_commit)
.await?;
let mut locked_ws = self.workspace.start_working_copy_mutation().await?;
locked_ws.locked_wc().reset(&new_git_head_commit).await?;
tx.repo_mut().rebase_descendants().await?;
self.operation = OperationData::new(
&workspace_name,
&self.data,
tx.commit("import git head").await?,
);
locked_ws
.finish(self.operation.repo.op_id().clone())
.await?;
} else {
self.finish_transaction(tx, "import git head").await?;
}
Ok(())
}
async fn import_git_refs(&mut self) -> Result<()> {
let git_settings = GitSettings::from_settings(&self.data.workspace_settings)?;
let remote_settings = self.data.workspace_settings.remote_settings()?;
let import_options = load_git_import_options(&Ui::null(), &git_settings, &remote_settings)
.map_err(|e| Error::new(e.error))?;
let mut tx = self.operation.repo.start_transaction();
let stats = git::import_refs(tx.repo_mut(), &import_options)
.await
.context("automated import failed despite reserved remote name")?;
if !tx.repo().has_changes() {
return Ok(());
}
tx.repo_mut().rebase_descendants().await?;
self.finish_transaction(tx, format!("import git refs: {:?}", stats))
.await?;
Ok(())
}
}
impl WorkspaceData {
fn parse_context<'a>(
&'a self,
name: &'a WorkspaceName,
store: &'a jj_lib::store::Store,
) -> RevsetParseContext<'a> {
let workspace_context = RevsetWorkspaceContext {
path_converter: &self.path_converter,
workspace_name: name,
};
let now = if let Some(timestamp) = self.workspace_settings.commit_timestamp() {
chrono::Local
.timestamp_millis_opt(timestamp.timestamp.0)
.unwrap()
} else {
chrono::Local::now()
};
RevsetParseContext {
aliases_map: &self.aliases_map,
local_variables: HashMap::new(),
user_email: self.workspace_settings.user_email(),
date_pattern_context: now.into(),
default_ignored_remote: default_ignored_remote_name(store),
fileset_aliases_map: &self.fileset_aliases_map,
extensions: &self.extensions,
workspace: Some(workspace_context),
use_glob_by_default: false,
}
}
pub fn auto_tracking_matcher(&self) -> Result<Box<dyn Matcher>> {
let pattern = self
.workspace_settings
.get_string("snapshot.auto-track")
.unwrap_or_else(|_| "all()".to_string());
let mut diagnostics = FilesetDiagnostics::new();
let parse_context = FilesetParseContext {
aliases_map: &self.fileset_aliases_map,
path_converter: &self.path_converter,
};
let expression = fileset::parse(&mut diagnostics, &pattern, &parse_context)?;
if let Some(diagnostic) = diagnostics.into_iter().next() {
return Err(anyhow!("snapshot.auto-track: {}", diagnostic));
}
Ok(expression.to_matcher())
}
}
impl OperationData {
pub fn new(id: &WorkspaceName, data: &WorkspaceData, repo: Arc<ReadonlyRepo>) -> OperationData {
let wc_id = repo
.view()
.get_wc_commit_id(id)
.expect("No working copy found for workspace")
.clone();
let revset_string: String = data
.workspace_settings
.get_string("revsets.short-prefixes")
.unwrap_or_else(|_| {
data.workspace_settings
.get_string("revsets.log")
.unwrap_or_default()
});
let prefix_context =
IdPrefixContext::default().disambiguate_within(if !revset_string.is_empty() {
parse_revset(&data.parse_context(id, repo.store()), &revset_string)
.expect("init prefix context: parse revsets.short-prefixes")
} else {
RevsetExpression::all()
});
OperationData {
repo,
wc_id,
ref_index: OnceCell::default(),
workspace_index: OnceCell::default(),
prefix_context,
}
}
fn git_backend(&self) -> Option<&GitBackend> {
self.repo.store().backend_impl::<GitBackend>()
}
pub fn base_ignores(&self, workspace_root: &Path) -> Result<Arc<GitIgnoreFile>> {
fn xdg_config_home() -> Option<PathBuf> {
if let Ok(x) = std::env::var("XDG_CONFIG_HOME")
&& !x.is_empty()
{
return Some(PathBuf::from(x));
}
etcetera::home_dir().ok().map(|home| home.join(".config"))
}
let get_excludes_file_path = |config: &gix::config::File| -> Option<PathBuf> {
if let Some(value) = config.string("core.excludesFile") {
let path = std::str::from_utf8(&value)
.ok()
.map(file_util::expand_home_path)?;
Some(workspace_root.join(path))
} else {
xdg_config_home().map(|x| x.join("git").join("ignore"))
}
};
let mut git_ignores = GitIgnoreFile::empty();
if let Some(git_backend) = self.git_backend() {
let git_repo = git_backend.git_repo();
if let Some(excludes_file_path) =
get_excludes_file_path(git_repo.config_snapshot().plumbing())
{
git_ignores = git_ignores.chain_with_file(RepoPath::root(), excludes_file_path)?;
}
git_ignores = git_ignores.chain_with_file(
RepoPath::root(),
git_backend.git_repo_path().join("info").join("exclude"),
)?;
} else if let Ok(git_config) = gix::config::File::from_globals()
&& let Some(excludes_file_path) = get_excludes_file_path(&git_config)
{
git_ignores = git_ignores.chain_with_file(RepoPath::root(), excludes_file_path)?;
}
Ok(git_ignores)
}
}
fn find_workspace_dir(cwd: &Path) -> &Path {
cwd.ancestors()
.find(|path| path.join(".jj").is_dir())
.unwrap_or(cwd)
}
fn absolute_workspace_path(path: &Path) -> Result<PathBuf> {
dunce::canonicalize(path)
.with_context(|| format!("Cannot resolve absolute workspace path: {}", path.display()))
}
fn hosts_repo(workspace_root: &Path, repo_path: &Path) -> bool {
let Ok(repo_path) = dunce::canonicalize(repo_path) else {
return false;
};
DefaultWorkspaceLoaderFactory
.create(workspace_root)
.ok()
.and_then(|loader| dunce::canonicalize(loader.repo_path()).ok())
.is_some_and(|loaded_path| loaded_path == repo_path)
}
fn parse_revset(
parse_context: &RevsetParseContext,
revision: &str,
) -> Result<Arc<UserRevsetExpression>, RevsetError> {
let mut diagnostics = RevsetDiagnostics::new(); let expression =
revset::parse(&mut diagnostics, revision, parse_context).context("parse revset")?;
let expression = revset::optimize(expression);
Ok(expression)
}
#[derive(Default)]
pub struct RefIndex {
index: HashMap<CommitId, Vec<messages::StoreRef>>,
}
impl RefIndex {
fn insert<'a>(
&mut self,
ids: impl IntoIterator<Item = &'a CommitId>,
r#ref: messages::StoreRef,
) {
for id in ids {
let ref_names = self.index.entry(id.clone()).or_default();
ref_names.push(r#ref.clone());
}
}
fn get(&self, id: &CommitId) -> &[messages::StoreRef] {
if let Some(names) = self.index.get(id) {
names
} else {
&[]
}
}
}
fn build_ref_index(repo: &ReadonlyRepo) -> RefIndex {
let potential_remotes = git::get_git_backend(repo.store())
.ok()
.map(|git_backend| git_backend.git_repo().remote_names().len())
.unwrap_or(0);
let mut index = RefIndex::default();
for (bookmark_name, bookmark_target) in repo.view().bookmarks() {
let local_target = bookmark_target.local_target;
let remote_refs = bookmark_target.remote_refs;
if local_target.is_present() {
index.insert(
local_target.added_ids(),
messages::StoreRef::LocalBookmark {
bookmark_name: bookmark_name.as_str().to_owned(),
has_conflict: local_target.has_conflict(),
is_synced: remote_refs.iter().all(|&(remote_name, remote_ref)| {
remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO
|| !remote_ref.is_tracked()
|| remote_ref.target == *local_target
}),
tracking_remotes: remote_refs
.iter()
.filter(|&(remote_name, remote_ref)| {
*remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO
&& remote_ref.is_tracked()
})
.map(|&(remote_name, _)| remote_name.as_str().to_owned())
.collect(),
available_remotes: remote_refs
.iter()
.filter(|&(remote_name, _)| *remote_name != REMOTE_NAME_FOR_LOCAL_GIT_REPO)
.count(),
potential_remotes,
},
);
}
for &(remote_name, remote_ref) in &remote_refs {
if remote_name == REMOTE_NAME_FOR_LOCAL_GIT_REPO {
continue;
}
index.insert(
remote_ref.target.added_ids(),
messages::StoreRef::RemoteBookmark {
bookmark_name: bookmark_name.as_str().to_owned(),
remote_name: remote_name.as_str().to_owned(),
has_conflict: remote_ref.target.has_conflict(),
is_synced: remote_ref.target == *local_target,
is_tracked: remote_ref.is_tracked(),
is_absent: local_target.is_absent(),
},
);
}
}
for (tag_name, tag_target) in repo.view().local_tags() {
index.insert(
tag_target.added_ids(),
messages::StoreRef::Tag {
tag_name: tag_name.as_str().to_owned(),
},
);
}
index
}
fn has_external_tool(settings: &UserSettings, config_key: &'static str) -> bool {
settings
.get::<jj_cli::config::CommandNameAndArgs>(config_key)
.ok()
.map(|tool| !matches!(tool.as_str(), Some(s) if s.is_empty() || s.starts_with(':')))
.unwrap_or(false)
}
async fn load_at_head(workspace: &Workspace, data: &WorkspaceData) -> Result<OperationData> {
let loader = workspace.repo_loader();
let op = op_heads_store::resolve_op_heads(
loader.op_heads_store().as_ref(),
loader.op_store(),
async |op_heads| {
let base_repo = loader.load_at(&op_heads[0]).await?;
let mut tx = base_repo.start_transaction();
for other_op_head in op_heads.into_iter().skip(1) {
tx.merge_operation(other_op_head).await?;
tx.repo_mut().rebase_descendants().await?;
}
Ok::<Operation, RepoLoaderError>(
tx.write("resolve concurrent operations")
.await?
.leave_unpublished()
.operation()
.clone(),
)
},
)
.await?;
let repo: Arc<ReadonlyRepo> = workspace
.repo_loader()
.load_at(&op)
.await
.context("load op head")?;
Ok(OperationData::new(workspace.workspace_name(), data, repo))
}