#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
pub use processkit::CancellationToken;
pub mod conflict;
mod parse;
pub use parse::{AnnotationLine, Bookmark, BookmarkRef, Change, ChangedPath, Operation, Workspace};
pub use vcs_diff::{
ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as JjVersion, parse_diff,
};
pub use vcs_cli_support::{
OutputBudget, RetryPolicy, is_lock_contention, is_transient_fetch_error,
};
pub const BINARY: &str = "jj";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SparseMode {
Copy,
Full,
Empty,
}
impl SparseMode {
fn as_arg(self) -> &'static str {
match self {
SparseMode::Copy => "copy",
SparseMode::Full => "full",
SparseMode::Empty => "empty",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JjFileset(String);
impl JjFileset {
pub fn path(path: impl AsRef<str>) -> Self {
let path = path.as_ref();
#[cfg(windows)]
let normalised = path.replace('\\', "/");
#[cfg(not(windows))]
let normalised = path.to_string();
let escaped = normalised.replace('\\', "\\\\").replace('"', "\\\"");
JjFileset(format!("root-file:\"{escaped}\""))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorkspaceAdd {
pub name: String,
pub base: RevsetExpr,
pub path: PathBuf,
pub sparse_patterns: Option<SparseMode>,
}
impl WorkspaceAdd {
pub fn new(name: impl Into<String>, base: RevsetExpr, path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
base,
path: path.into(),
sparse_patterns: None,
}
}
pub fn sparse(mut self, mode: SparseMode) -> Self {
self.sparse_patterns = Some(mode);
self
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SquashPaths {
pub from: RevsetExpr,
pub into: RevsetExpr,
pub filesets: Vec<JjFileset>,
pub use_destination_message: bool,
}
impl SquashPaths {
pub fn new(from: RevsetExpr, into: RevsetExpr) -> Self {
Self {
from,
into,
filesets: Vec::new(),
use_destination_message: false,
}
}
pub fn filesets(mut self, filesets: impl IntoIterator<Item = JjFileset>) -> Self {
self.filesets = filesets.into_iter().collect();
self
}
pub fn use_destination_message(mut self) -> Self {
self.use_destination_message = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BookmarkMove {
pub name: BookmarkName,
pub to: RevsetExpr,
pub allow_backwards: bool,
}
impl BookmarkMove {
pub fn new(name: BookmarkName, to: RevsetExpr) -> Self {
Self {
name,
to,
allow_backwards: false,
}
}
pub fn allow_backwards(mut self) -> Self {
self.allow_backwards = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SquashInto {
pub into: RevsetExpr,
pub use_destination_message: bool,
}
impl SquashInto {
pub fn new(into: RevsetExpr) -> Self {
Self {
into,
use_destination_message: false,
}
}
pub fn use_destination_message(mut self) -> Self {
self.use_destination_message = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct GitClone {
pub colocate: bool,
}
impl GitClone {
pub fn colocated() -> Self {
Self { colocate: true }
}
pub fn separate() -> Self {
Self { colocate: false }
}
}
fn first_bookmark(rendered: &str) -> Option<String> {
parse::first_bookmark_name(rendered)
}
fn reject_flag_like(what: &str, value: &str) -> Result<()> {
vcs_cli_support::reject_flag_like(BINARY, what, value)
}
fn at_revset() -> RevsetExpr {
RevsetExpr::new("@").expect("`@` is a valid revset")
}
fn exact(name: &str) -> String {
format!("exact:{name}")
}
fn reject_glob_like(what: &str, value: &str) -> Result<()> {
if value.contains(['*', '?', '[', ']']) {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{what} {value:?} contains a glob metacharacter and could fan out across \
remotes — refusing to pass it as a positional argument"
),
),
));
}
Ok(())
}
fn c_locale(cmd: processkit::Command) -> processkit::Command {
cmd.env("LC_ALL", "C")
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RevsetExpr(String);
impl RevsetExpr {
pub fn new(revset: impl Into<String>) -> Result<Self> {
let revset = revset.into();
reject_flag_like("revset", &revset)?;
Ok(RevsetExpr(revset))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for RevsetExpr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for RevsetExpr {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BookmarkName(String);
impl BookmarkName {
pub fn new(name: impl Into<String>) -> Result<Self> {
let name = name.into();
reject_flag_like("bookmark name", &name)?;
Ok(BookmarkName(name))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for BookmarkName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for BookmarkName {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::new(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct JjCapabilities {
pub version: JjVersion,
}
const MIN_SUPPORTED: JjVersion = JjVersion {
major: 0,
minor: 38,
patch: 0,
};
impl JjCapabilities {
pub fn is_supported(&self) -> bool {
self.version >= MIN_SUPPORTED
}
pub fn ensure_supported(&self) -> Result<()> {
if self.is_supported() {
return Ok(());
}
Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"vcs-jj requires jj >= {MIN_SUPPORTED} (the validated floor), found {}",
self.version
),
),
))
}
}
#[cfg_attr(feature = "mock", mockall::automock)]
#[async_trait::async_trait]
pub trait JjApi: Send + Sync {
async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
async fn version(&self) -> Result<String>;
async fn capabilities(&self) -> Result<JjCapabilities>;
async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>>;
async fn status_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<ChangedPath>>;
async fn status_text(&self, dir: &Path) -> Result<String>;
async fn log(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
async fn log_paths(
&self,
dir: &Path,
revset: &RevsetExpr,
max: usize,
filesets: &[JjFileset],
) -> Result<Vec<Change>>;
async fn current_change(&self, dir: &Path) -> Result<Change>;
async fn describe(&self, dir: &Path, message: &str) -> Result<()>;
async fn describe_rev(&self, dir: &Path, revset: &RevsetExpr, message: &str) -> Result<()>;
async fn new_change(&self, dir: &Path, message: &str) -> Result<()>;
async fn new_child(&self, dir: &Path, parent: &RevsetExpr) -> Result<()>;
async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
async fn bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>>;
async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>>;
async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
async fn reachable_bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>>;
async fn bookmark_track(&self, dir: &Path, name: &BookmarkName, remote: &str) -> Result<()>;
async fn bookmark_set(
&self,
dir: &Path,
name: &BookmarkName,
revision: &RevsetExpr,
) -> Result<()>;
async fn git_fetch(&self, dir: &Path) -> Result<()>;
async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
async fn git_push(&self, dir: &Path, bookmark: Option<BookmarkName>) -> Result<()>;
async fn root(&self, dir: &Path) -> Result<PathBuf>;
async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>>;
async fn trunk(&self, dir: &Path) -> Result<Option<String>>;
async fn bookmark_create(
&self,
dir: &Path,
name: &BookmarkName,
revision: &RevsetExpr,
) -> Result<()>;
async fn bookmark_rename(
&self,
dir: &Path,
old: &BookmarkName,
new: &BookmarkName,
) -> Result<()>;
async fn bookmark_delete(&self, dir: &Path, name: &BookmarkName) -> Result<()>;
async fn bookmark_move(&self, dir: &Path, spec: BookmarkMove) -> Result<()>;
async fn diff_summary(
&self,
dir: &Path,
from: &RevsetExpr,
to: &RevsetExpr,
) -> Result<Vec<ChangedPath>>;
async fn diff_stat(&self, dir: &Path, revset: &RevsetExpr) -> Result<DiffStat>;
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
async fn commit_count(&self, dir: &Path, revset: &RevsetExpr) -> Result<usize>;
async fn is_conflicted(&self, dir: &Path, revset: &RevsetExpr) -> Result<bool>;
async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool>;
async fn resolve_list(&self, dir: &Path, revset: &RevsetExpr) -> Result<Vec<PathBuf>>;
async fn template_query(
&self,
dir: &Path,
revset: &RevsetExpr,
template: &str,
limit: Option<usize>,
) -> Result<String>;
async fn template_query_ignoring_working_copy(
&self,
dir: &Path,
revset: &RevsetExpr,
template: &str,
limit: Option<usize>,
) -> Result<String>;
async fn description(&self, dir: &Path, revset: &RevsetExpr) -> Result<String>;
async fn evolog(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
async fn file_annotate(
&self,
dir: &Path,
path: &str,
revset: Option<RevsetExpr>,
) -> Result<Vec<AnnotationLine>>;
async fn file_show(&self, dir: &Path, revset: &RevsetExpr, path: &str) -> Result<String>;
async fn rebase(&self, dir: &Path, onto: &RevsetExpr) -> Result<()>;
async fn rebase_branch(&self, dir: &Path, branch: &RevsetExpr, dest: &RevsetExpr)
-> Result<()>;
async fn edit(&self, dir: &Path, revset: &RevsetExpr) -> Result<()>;
async fn squash_into(&self, dir: &Path, spec: SquashInto) -> Result<()>;
async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()>;
async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()>;
async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<RevsetExpr>) -> Result<()>;
async fn abandon(&self, dir: &Path, revset: &RevsetExpr) -> Result<()>;
async fn git_fetch_branch(&self, dir: &Path, branch: &BookmarkName) -> Result<()>;
async fn git_import(&self, dir: &Path) -> Result<()>;
async fn git_clone(&self, url: &str, dest: &Path, spec: GitClone) -> Result<()>;
async fn absorb(
&self,
dir: &Path,
from: Option<RevsetExpr>,
filesets: &[JjFileset],
) -> Result<()>;
async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
async fn duplicate(&self, dir: &Path, revset: &RevsetExpr) -> Result<()>;
async fn op_head(&self, dir: &Path) -> Result<String>;
async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>>;
async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()>;
async fn op_undo(&self, dir: &Path) -> Result<()>;
async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>>;
async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf>;
async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()>;
async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()>;
}
vcs_cli_support::managed_client! {
pub struct Jj => BINARY
}
fn normalize_changed_paths(entries: Vec<ChangedPath>) -> Result<Vec<ChangedPath>> {
entries
.into_iter()
.map(|mut entry| {
entry.path = normalize_workspace_path(&entry.path)?;
entry.old_path = entry
.old_path
.as_deref()
.map(normalize_workspace_path)
.transpose()?;
Ok(entry)
})
.collect()
}
fn normalize_workspace_path(path: &Path) -> Result<PathBuf> {
let raw = path.as_os_str().as_encoded_bytes();
let normalized: Vec<u8> = raw
.iter()
.map(|&b| if b == b'\\' { b'/' } else { b })
.collect();
if normalized.first() == Some(&b'/') || (normalized.len() >= 2 && normalized[1] == b':') {
return Err(Error::parse(
BINARY,
format!("summary path is not workspace-relative: {path:?}"),
));
}
let mut parts: Vec<&[u8]> = Vec::new();
for part in normalized.split(|&b| b == b'/') {
match part {
b"" | b"." => {}
b".." => {
return Err(Error::parse(
BINARY,
format!("summary path escapes the workspace root: {path:?}"),
));
}
_ => parts.push(part),
}
}
if parts.is_empty() {
return Err(Error::parse(
BINARY,
format!("summary path is empty after normalisation: {path:?}"),
));
}
let mut joined = Vec::new();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
joined.push(b'/');
}
joined.extend_from_slice(part);
}
Ok(vcs_diff::path_from_bytes(&joined))
}
impl<R: ProcessRunner> Jj<R> {
pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
self.core = self.core.with_retry(policy);
self
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum WorkingCopy {
Snapshot,
Ignore,
}
impl<R: ProcessRunner> Jj<R> {
fn cmd_in<I, S>(&self, dir: &Path, args: I) -> processkit::Command
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
self.cmd_in_wc(dir, args, WorkingCopy::Snapshot)
}
fn cmd_in_wc<I, S>(&self, dir: &Path, args: I, wc: WorkingCopy) -> processkit::Command
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let cmd = self.core.command_in(dir, args).arg("--color").arg("never");
match wc {
WorkingCopy::Snapshot => cmd,
WorkingCopy::Ignore => cmd.arg("--ignore-working-copy"),
}
}
async fn status_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<Vec<ChangedPath>> {
let root = self.root_wc(dir, wc).await?;
let entries = self
.core
.parse_bytes(
self.cmd_in_wc(&root, ["diff", "-r", "@", "--summary"], wc),
parse::parse_diff_summary,
)
.await?;
normalize_changed_paths(entries)
}
async fn root_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core.run(self.cmd_in_wc(dir, ["root"], wc)).await?,
))
}
async fn bookmarks_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<Vec<Bookmark>> {
self.core
.parse(
self.cmd_in_wc(
dir,
["bookmark", "list", "-T", parse::BOOKMARK_LIST_TEMPLATE],
wc,
),
parse::parse_bookmarks,
)
.await
}
async fn reachable_bookmarks_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<Vec<Bookmark>> {
self.core
.parse(
self.cmd_in_wc(
dir,
[
"log",
"-r",
"heads(::@ & bookmarks())",
"--no-graph",
"-T",
parse::REACHABLE_BOOKMARKS_TEMPLATE,
],
wc,
),
parse::parse_reachable_bookmarks,
)
.await
}
async fn template_query_wc(
&self,
dir: &Path,
revset: &RevsetExpr,
template: &str,
limit: Option<usize>,
wc: WorkingCopy,
) -> Result<String> {
let mut args: Vec<String> = vec![
"log".into(),
"-r".into(),
revset.as_str().into(),
"--no-graph".into(),
];
if let Some(n) = limit {
args.push("--limit".into());
args.push(n.to_string());
}
args.push("-T".into());
args.push(template.into());
self.core.run_untrimmed(self.cmd_in_wc(dir, args, wc)).await
}
pub async fn diff_text_within(
&self,
dir: &Path,
spec: DiffSpec,
budget: OutputBudget,
) -> Result<String> {
self.diff_text_budgeted(dir, spec, budget).await
}
pub async fn diff_within(
&self,
dir: &Path,
spec: DiffSpec,
budget: OutputBudget,
) -> Result<Vec<FileDiff>> {
let text = self.diff_text_budgeted(dir, spec, budget).await?;
Ok(parse_diff(&text))
}
async fn diff_text_budgeted(
&self,
dir: &Path,
spec: DiffSpec,
budget: OutputBudget,
) -> Result<String> {
let revset = match spec {
DiffSpec::WorkingTree => "@".to_string(),
DiffSpec::Rev(rev) => rev,
};
self.core
.run_untrimmed_within(
self.cmd_in(dir, ["diff", "-r", revset.as_str(), "--git"]),
budget,
)
.await
}
pub async fn file_show_within(
&self,
dir: &Path,
revset: &RevsetExpr,
path: &str,
budget: OutputBudget,
) -> Result<String> {
let fileset = JjFileset::path(path);
self.core
.run_untrimmed_within(
self.cmd_in(
dir,
["file", "show", "-r", revset.as_str(), fileset.as_str()],
),
budget,
)
.await
}
}
#[async_trait::async_trait]
impl<R: ProcessRunner> JjApi for Jj<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(args).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output_string(args).await
}
async fn version(&self) -> Result<String> {
self.core.run(["--version"]).await
}
async fn capabilities(&self) -> Result<JjCapabilities> {
let raw = self.version().await?;
let version = parse::parse_jj_version(&raw).ok_or_else(|| {
Error::parse(
BINARY,
format!("unrecognisable `jj --version` output: {raw:?}"),
)
})?;
Ok(JjCapabilities { version })
}
async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>> {
self.status_wc(dir, WorkingCopy::Snapshot).await
}
async fn status_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<ChangedPath>> {
self.status_wc(dir, WorkingCopy::Ignore).await
}
async fn status_text(&self, dir: &Path) -> Result<String> {
self.core.run(self.cmd_in(dir, ["status"])).await
}
async fn log(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>> {
let n = format!("-n{max}");
self.core
.parse(
self.cmd_in(
dir,
[
"log",
"-r",
revset.as_str(),
n.as_str(),
"--no-graph",
"-T",
parse::CHANGE_TEMPLATE,
],
),
parse::parse_changes,
)
.await
}
async fn log_paths(
&self,
dir: &Path,
revset: &RevsetExpr,
max: usize,
filesets: &[JjFileset],
) -> Result<Vec<Change>> {
if filesets.is_empty() {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"log_paths requires at least one fileset — an empty set would log \
unrestricted history, not history scoped to the named paths",
),
));
}
let n = format!("-n{max}");
let mut args: Vec<String> = vec![
"log".into(),
"-r".into(),
revset.as_str().into(),
n,
"--no-graph".into(),
"-T".into(),
parse::CHANGE_TEMPLATE.into(),
];
args.extend(filesets.iter().map(|f| f.as_str().to_string()));
self.core
.parse(self.cmd_in(dir, args), parse::parse_changes)
.await
}
async fn current_change(&self, dir: &Path) -> Result<Change> {
let mut changes = self.log(dir, &at_revset(), 1).await?;
changes
.pop()
.ok_or_else(|| Error::parse(BINARY, "no working-copy change found"))
}
async fn describe(&self, dir: &Path, message: &str) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["describe", "-m", message]))
.await
}
async fn describe_rev(&self, dir: &Path, revset: &RevsetExpr, message: &str) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["describe", "-r", revset.as_str(), "-m", message]))
.await
}
async fn new_change(&self, dir: &Path, message: &str) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["new", "-m", message]))
.await
}
async fn new_child(&self, dir: &Path, parent: &RevsetExpr) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["new", parent.as_str()]))
.await
}
async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
self.bookmarks_wc(dir, WorkingCopy::Snapshot).await
}
async fn bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>> {
self.bookmarks_wc(dir, WorkingCopy::Ignore).await
}
async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>> {
self.core
.parse(
self.cmd_in(
dir,
["bookmark", "list", "-a", "-T", parse::BOOKMARK_ALL_TEMPLATE],
),
parse::parse_bookmarks_all,
)
.await
}
async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
self.reachable_bookmarks_wc(dir, WorkingCopy::Snapshot)
.await
}
async fn reachable_bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>> {
self.reachable_bookmarks_wc(dir, WorkingCopy::Ignore).await
}
async fn bookmark_track(&self, dir: &Path, name: &BookmarkName, remote: &str) -> Result<()> {
reject_glob_like("remote", remote)?;
let target = format!("exact:{}@{remote}", name.as_str());
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "track", target.as_str()]))
.await
}
async fn bookmark_set(
&self,
dir: &Path,
name: &BookmarkName,
revision: &RevsetExpr,
) -> Result<()> {
self.core
.run_unit(self.cmd_in(
dir,
["bookmark", "set", name.as_str(), "-r", revision.as_str()],
))
.await
}
async fn git_fetch(&self, dir: &Path) -> Result<()> {
let cmd = self.core.budget_diagnostics(
c_locale(self.cmd_in(dir, ["git", "fetch"]))
.timeout_grace(FETCH_TIMEOUT_GRACE)
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
);
self.core.run_unit(cmd).await
}
async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
let remote_pat = exact(remote);
let cmd = self.core.budget_diagnostics(
c_locale(self.cmd_in(dir, ["git", "fetch", "--remote", remote_pat.as_str()]))
.timeout_grace(FETCH_TIMEOUT_GRACE)
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
);
self.core.run_unit(cmd).await
}
async fn git_push(&self, dir: &Path, bookmark: Option<BookmarkName>) -> Result<()> {
let mut args = vec!["git", "push"];
let bookmark_pat = bookmark.as_ref().map(|b| exact(b.as_str()));
if let Some(name) = bookmark_pat.as_deref() {
args.push("-b");
args.push(name);
}
let cmd = self.cmd_in(dir, args).timeout_grace(FETCH_TIMEOUT_GRACE);
self.core.run_unit(cmd).await
}
async fn root(&self, dir: &Path) -> Result<PathBuf> {
self.root_wc(dir, WorkingCopy::Snapshot).await
}
async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>> {
let out = self
.core
.run(self.cmd_in(
dir,
[
"log",
"-r",
"@",
"--no-graph",
"--limit",
"1",
"-T",
parse::BOOKMARKS_TEMPLATE,
],
))
.await?;
Ok(first_bookmark(&out))
}
async fn trunk(&self, dir: &Path) -> Result<Option<String>> {
let out = self
.core
.run(self.cmd_in(
dir,
[
"log",
"-r",
"trunk()",
"--no-graph",
"--limit",
"1",
"-T",
parse::BOOKMARKS_TEMPLATE,
],
))
.await?;
Ok(first_bookmark(&out))
}
async fn bookmark_create(
&self,
dir: &Path,
name: &BookmarkName,
revision: &RevsetExpr,
) -> Result<()> {
self.core
.run_unit(self.cmd_in(
dir,
["bookmark", "create", name.as_str(), "-r", revision.as_str()],
))
.await
}
async fn bookmark_rename(
&self,
dir: &Path,
old: &BookmarkName,
new: &BookmarkName,
) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "rename", old.as_str(), new.as_str()]))
.await
}
async fn bookmark_delete(&self, dir: &Path, name: &BookmarkName) -> Result<()> {
let name_pat = exact(name.as_str());
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "delete", name_pat.as_str()]))
.await
}
async fn bookmark_move(&self, dir: &Path, spec: BookmarkMove) -> Result<()> {
let name_pat = exact(spec.name.as_str());
let mut args = vec![
"bookmark",
"move",
name_pat.as_str(),
"--to",
spec.to.as_str(),
];
if spec.allow_backwards {
args.push("--allow-backwards");
}
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn diff_summary(
&self,
dir: &Path,
from: &RevsetExpr,
to: &RevsetExpr,
) -> Result<Vec<ChangedPath>> {
let range = format!("({})..({})", from.as_str(), to.as_str());
let root = self.root(dir).await?;
let entries = self
.core
.parse_bytes(
self.cmd_in(&root, ["diff", "-r", range.as_str(), "--summary"]),
parse::parse_diff_summary,
)
.await?;
normalize_changed_paths(entries)
}
async fn diff_stat(&self, dir: &Path, revset: &RevsetExpr) -> Result<DiffStat> {
self.core
.parse(
self.cmd_in(dir, ["diff", "-r", revset.as_str(), "--stat"]),
parse::parse_diff_stat,
)
.await
}
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
self.diff_text_budgeted(dir, spec, self.core.output_budget())
.await
}
async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
let text = self.diff_text(dir, spec).await?;
Ok(parse_diff(&text))
}
async fn commit_count(&self, dir: &Path, revset: &RevsetExpr) -> Result<usize> {
self.core
.parse(
self.cmd_in(
dir,
[
"log",
"-r",
revset.as_str(),
"--no-graph",
"-T",
parse::COUNT_TEMPLATE,
],
),
|s| s.lines().filter(|line| !line.is_empty()).count(),
)
.await
}
async fn is_conflicted(&self, dir: &Path, revset: &RevsetExpr) -> Result<bool> {
let out = self
.core
.run(self.cmd_in(
dir,
[
"log",
"-r",
revset.as_str(),
"--no-graph",
"--limit",
"1",
"-T",
parse::CONFLICT_TEMPLATE,
],
))
.await?;
Ok(out.trim() == "1")
}
async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool> {
self.is_conflicted(dir, &at_revset()).await
}
async fn resolve_list(&self, dir: &Path, revset: &RevsetExpr) -> Result<Vec<PathBuf>> {
let res = self
.core
.output_bytes(self.cmd_in(dir, ["resolve", "--list", "-r", revset.as_str()]))
.await?;
match res.code() {
Some(0) => Ok(parse::parse_resolve_list(res.stdout())),
_ if res.stderr().to_ascii_lowercase().contains("no conflicts") => Ok(Vec::new()),
_ => {
let _ = res.ensure_success()?;
Ok(Vec::new()) }
}
}
async fn template_query(
&self,
dir: &Path,
revset: &RevsetExpr,
template: &str,
limit: Option<usize>,
) -> Result<String> {
self.template_query_wc(dir, revset, template, limit, WorkingCopy::Snapshot)
.await
}
async fn template_query_ignoring_working_copy(
&self,
dir: &Path,
revset: &RevsetExpr,
template: &str,
limit: Option<usize>,
) -> Result<String> {
self.template_query_wc(dir, revset, template, limit, WorkingCopy::Ignore)
.await
}
async fn description(&self, dir: &Path, revset: &RevsetExpr) -> Result<String> {
let out = self
.template_query(dir, revset, "description", Some(1))
.await?;
Ok(out.trim_end().to_string())
}
async fn evolog(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>> {
let limit = max.to_string();
self.core
.parse(
self.cmd_in(
dir,
[
"evolog",
"-r",
revset.as_str(),
"--no-graph",
"--limit",
limit.as_str(),
"-T",
parse::EVOLOG_TEMPLATE,
],
),
parse::parse_changes,
)
.await
}
async fn file_annotate(
&self,
dir: &Path,
path: &str,
revset: Option<RevsetExpr>,
) -> Result<Vec<AnnotationLine>> {
let mut args = vec!["file", "annotate"];
if let Some(revset) = revset.as_ref() {
args.push("-r");
args.push(revset.as_str());
}
args.extend([
"-T",
parse::ANNOTATE_TEMPLATE,
"--color",
"never",
"--",
path,
]);
self.core
.parse(self.core.command_in(dir, args), parse::parse_annotate)
.await
}
async fn file_show(&self, dir: &Path, revset: &RevsetExpr, path: &str) -> Result<String> {
self.file_show_within(dir, revset, path, self.core.output_budget())
.await
}
async fn rebase(&self, dir: &Path, onto: &RevsetExpr) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["rebase", "-d", onto.as_str()]))
.await
}
async fn rebase_branch(
&self,
dir: &Path,
branch: &RevsetExpr,
dest: &RevsetExpr,
) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["rebase", "-b", branch.as_str(), "-d", dest.as_str()]))
.await
}
async fn edit(&self, dir: &Path, revset: &RevsetExpr) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["edit", revset.as_str()]))
.await
}
async fn squash_into(&self, dir: &Path, spec: SquashInto) -> Result<()> {
let mut command = self.cmd_in(dir, ["squash", "--into", spec.into.as_str()]);
if spec.use_destination_message {
command = command.arg("--use-destination-message");
}
self.core.run_unit(command).await
}
async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
if filesets.is_empty() {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"commit_paths requires at least one fileset — an empty set would \
commit the entire working copy, not just the named paths",
),
));
}
let mut args: Vec<String> = vec!["commit".into(), "-m".into(), message.into()];
args.extend(filesets.iter().map(|f| f.as_str().to_string()));
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()> {
let mut args: Vec<String> = vec![
"squash".into(),
"--from".into(),
spec.from.as_str().into(),
"--into".into(),
spec.into.as_str().into(),
];
if spec.use_destination_message {
args.push("--use-destination-message".into());
}
args.extend(spec.filesets.iter().map(|f| f.as_str().to_string()));
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()> {
let mut args: Vec<String> = vec!["sparse".into(), "set".into(), "--clear".into()];
for pattern in patterns {
args.push("--add".into());
args.push(pattern.clone());
}
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<RevsetExpr>) -> Result<()> {
let mut args: Vec<String> = vec!["new".into(), "-m".into(), message.into()];
args.extend(parents.iter().map(|p| p.as_str().to_string()));
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn abandon(&self, dir: &Path, revset: &RevsetExpr) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["abandon", revset.as_str()]))
.await
}
async fn git_fetch_branch(&self, dir: &Path, branch: &BookmarkName) -> Result<()> {
let branch_pat = exact(branch.as_str());
let cmd = c_locale(self.cmd_in(
dir,
[
"git",
"fetch",
"--remote",
"origin",
"-b",
branch_pat.as_str(),
],
))
.timeout_grace(FETCH_TIMEOUT_GRACE)
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
self.core.run_unit(cmd).await
}
async fn git_import(&self, dir: &Path) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["git", "import"]))
.await
}
async fn git_clone(&self, url: &str, dest: &Path, spec: GitClone) -> Result<()> {
reject_flag_like("url", url)?;
let command = self
.core
.command(["git", "clone", url])
.arg(dest)
.arg(if spec.colocate {
"--colocate"
} else {
"--no-colocate"
});
let command = self.core.budget_diagnostics(
command
.arg("--color")
.arg("never")
.timeout_grace(FETCH_TIMEOUT_GRACE),
);
let cleanable = vcs_cli_support::clone_dest_cleanable(dest);
let result = self.core.run_unit(command).await;
if result.is_err() {
vcs_cli_support::cleanup_failed_clone_dest(dest, cleanable);
}
result
}
async fn absorb(
&self,
dir: &Path,
from: Option<RevsetExpr>,
filesets: &[JjFileset],
) -> Result<()> {
let mut args: Vec<String> = vec!["absorb".into()];
if let Some(from) = from.as_ref() {
args.push("--from".into());
args.push(from.as_str().into());
}
args.extend(filesets.iter().map(|f| f.as_str().to_string()));
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
if filesets.is_empty() {
return Err(Error::spawn(
BINARY,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"split_paths requires at least one fileset — an empty split \
opens jj's interactive diff editor",
),
));
}
let mut args: Vec<String> = vec!["split".into(), "-m".into(), message.into()];
args.extend(filesets.iter().map(|f| f.as_str().to_string()));
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn duplicate(&self, dir: &Path, revset: &RevsetExpr) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["duplicate", revset.as_str()]))
.await
}
async fn op_head(&self, dir: &Path) -> Result<String> {
self.core
.run(self.cmd_in(
dir,
[
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id.short()",
],
))
.await
}
async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>> {
let limit = limit.to_string();
self.core
.parse(
self.cmd_in(
dir,
[
"op",
"log",
"--no-graph",
"--limit",
limit.as_str(),
"-T",
parse::OP_TEMPLATE,
],
),
parse::parse_operations,
)
.await
}
async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()> {
reject_flag_like("operation id", op_id)?;
self.core
.run_unit(self.cmd_in(dir, ["op", "restore", op_id]))
.await
}
async fn op_undo(&self, dir: &Path) -> Result<()> {
self.core.run_unit(self.cmd_in(dir, ["op", "undo"])).await
}
async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>> {
self.core
.parse(
self.cmd_in(dir, ["workspace", "list", "-T", parse::WORKSPACE_TEMPLATE]),
parse::parse_workspaces,
)
.await
}
async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf> {
let mut args: Vec<String> = vec![
"--ignore-working-copy".into(),
"workspace".into(),
"root".into(),
];
if let Some(n) = name.as_deref() {
args.push("--name".into());
args.push(n.to_string());
}
self.core
.parse_bytes(self.cmd_in(dir, args), parse::workspace_root_from_bytes)
.await
}
async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()> {
let mut command = self
.core
.command_in(dir, ["workspace", "add", "--name"])
.arg(&spec.name)
.arg("-r")
.arg(spec.base.as_str());
if let Some(mode) = spec.sparse_patterns {
command = command.arg("--sparse-patterns").arg(mode.as_arg());
}
command = command.arg(&spec.path).arg("--color").arg("never");
self.core.run_unit(command).await
}
async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()> {
reject_flag_like("workspace name", name)?;
self.core
.run_unit(self.cmd_in(dir, ["workspace", "forget", name]))
.await
}
}
const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
const WORKSPACE_ROOTS_CONCURRENCY: usize = 8;
const ROLLBACK_TIMEOUT: Duration = Duration::from_secs(30);
const ROLLBACK_PROBE_LIMIT: &str = "256";
#[derive(Debug)]
#[non_exhaustive]
pub enum Rollback {
Restored,
SkippedDiverged,
Failed(Error),
NotAttempted,
}
impl Rollback {
pub fn is_restored(&self) -> bool {
matches!(self, Rollback::Restored)
}
pub fn failure(&self) -> Option<&Error> {
match self {
Rollback::Failed(err) => Some(err),
_ => None,
}
}
}
impl std::fmt::Display for Rollback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Rollback::Restored => f.write_str("rolled back to the captured operation"),
Rollback::SkippedDiverged => f.write_str(
"rollback skipped: the operation log diverged (a concurrent jj process \
advanced it), so reverting was refused to avoid clobbering that work",
),
Rollback::Failed(err) => write!(f, "rollback failed: {err}"),
Rollback::NotAttempted => f.write_str("no rollback was attempted"),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct TransactionError {
pub cause: Error,
pub rollback: Rollback,
}
impl TransactionError {
pub fn cause(&self) -> &Error {
&self.cause
}
pub fn rollback(&self) -> &Rollback {
&self.rollback
}
pub fn into_cause(self) -> Error {
self.cause
}
}
impl std::fmt::Display for TransactionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "transaction failed: {} ({})", self.cause, self.rollback)
}
}
impl std::error::Error for TransactionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.cause)
}
}
enum RollbackPlan {
Restore,
SkipDiverged,
}
fn rollback_plan(rows: &[(String, usize)], pre: &str) -> RollbackPlan {
for (id, parents) in rows {
if id == pre {
return RollbackPlan::Restore;
}
if *parents >= 2 {
return RollbackPlan::SkipDiverged;
}
}
RollbackPlan::SkipDiverged
}
impl<R: ProcessRunner> Jj<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.run(args).await
}
pub async fn workspace_roots(&self, dir: &Path, names: &[String]) -> Vec<Result<PathBuf>> {
let commands = names.iter().map(|n| {
self.cmd_in(
dir,
[
"--ignore-working-copy",
"workspace",
"root",
"--name",
n.as_str(),
],
)
});
processkit::output_all_bytes(commands, WORKSPACE_ROOTS_CONCURRENCY, self.core.runner())
.await
.into_iter()
.map(|r| {
r.and_then(|pr| pr.ensure_success())
.map(|pr| parse::workspace_root_from_bytes(pr.stdout()))
})
.collect()
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output_string(args).await
}
pub async fn run_in(&self, dir: &Path, args: &[String]) -> Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
pub async fn run_raw_in(&self, dir: &Path, args: &[String]) -> Result<ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
pub async fn run_args_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
self.core.run(self.core.command_in(dir, args)).await
}
pub async fn run_raw_args_in(
&self,
dir: &Path,
args: &[&str],
) -> Result<ProcessResult<String>> {
self.core
.output_string(self.core.command_in(dir, args))
.await
}
pub fn at<'a>(&'a self, dir: &'a Path) -> JjAt<'a, R> {
JjAt { jj: self, dir }
}
fn rollback_cmd_in<I, S>(&self, dir: &Path, args: I) -> processkit::Command
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
self.cmd_in(dir, args)
.cancel_on(CancellationToken::new())
.timeout(ROLLBACK_TIMEOUT)
}
async fn op_log_parents_probe(&self, dir: &Path) -> Result<Vec<(String, usize)>> {
let out = self
.core
.run(self.rollback_cmd_in(
dir,
[
"op",
"log",
"--no-graph",
"--ignore-working-copy",
"--limit",
ROLLBACK_PROBE_LIMIT,
"-T",
parse::OP_PARENTS_TEMPLATE,
],
))
.await?;
Ok(parse::parse_op_parents(&out))
}
async fn op_restore_detached(&self, dir: &Path, op_id: &str) -> Result<()> {
reject_flag_like("operation id", op_id)?;
self.core
.run_unit(self.rollback_cmd_in(dir, ["op", "restore", op_id]))
.await
}
pub async fn rollback_to(&self, dir: &Path, pre: &str) -> Rollback {
match self.op_log_parents_probe(dir).await {
Err(err) => Rollback::Failed(err),
Ok(rows) => match rollback_plan(&rows, pre) {
RollbackPlan::SkipDiverged => Rollback::SkippedDiverged,
RollbackPlan::Restore => match self.op_restore_detached(dir, pre).await {
Ok(()) => Rollback::Restored,
Err(err) => Rollback::Failed(err),
},
},
}
}
pub async fn transaction<'a, T, F, Fut>(
&'a self,
dir: &'a Path,
f: F,
) -> std::result::Result<T, TransactionError>
where
F: FnOnce(JjAt<'a, R>) -> Fut,
Fut: Future<Output = Result<T>> + 'a,
{
let pre = match self.op_head(dir).await {
Ok(pre) => pre,
Err(cause) => {
return Err(TransactionError {
cause,
rollback: Rollback::NotAttempted,
});
}
};
match f(self.at(dir)).await {
Ok(value) => Ok(value),
Err(cause) => {
let rollback = self.rollback_to(dir, &pre).await;
Err(TransactionError { cause, rollback })
}
}
}
}
pub struct JjAt<'a, R: ProcessRunner = processkit::JobRunner> {
jj: &'a Jj<R>,
dir: &'a Path,
}
impl<R: ProcessRunner> Clone for JjAt<'_, R> {
fn clone(&self) -> Self {
*self
}
}
impl<R: ProcessRunner> Copy for JjAt<'_, R> {}
vcs_cli_support::at_forwarders! {
JjAt, jj, "Jj",
bare {
fn version() -> Result<String>;
fn capabilities() -> Result<JjCapabilities>;
fn git_clone(url: &str, dest: &Path, spec: GitClone) -> Result<()>;
}
dir {
fn status() -> Result<Vec<ChangedPath>>;
fn status_text() -> Result<String>;
fn log(revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
fn log_paths(revset: &RevsetExpr, max: usize, filesets: &[JjFileset]) -> Result<Vec<Change>>;
fn current_change() -> Result<Change>;
fn describe(message: &str) -> Result<()>;
fn describe_rev(revset: &RevsetExpr, message: &str) -> Result<()>;
fn new_change(message: &str) -> Result<()>;
fn new_child(parent: &RevsetExpr) -> Result<()>;
fn bookmarks() -> Result<Vec<Bookmark>>;
fn bookmarks_all() -> Result<Vec<BookmarkRef>>;
fn reachable_bookmarks() -> Result<Vec<Bookmark>>;
fn bookmark_track(name: &BookmarkName, remote: &str) -> Result<()>;
fn bookmark_set(name: &BookmarkName, revision: &RevsetExpr) -> Result<()>;
fn git_fetch() -> Result<()>;
fn git_fetch_from(remote: &str) -> Result<()>;
fn git_push(bookmark: Option<BookmarkName>) -> Result<()>;
fn root() -> Result<PathBuf>;
fn current_bookmark() -> Result<Option<String>>;
fn trunk() -> Result<Option<String>>;
fn bookmark_create(name: &BookmarkName, revision: &RevsetExpr) -> Result<()>;
fn bookmark_rename(old: &BookmarkName, new: &BookmarkName) -> Result<()>;
fn bookmark_delete(name: &BookmarkName) -> Result<()>;
fn bookmark_move(spec: BookmarkMove) -> Result<()>;
fn diff_summary(from: &RevsetExpr, to: &RevsetExpr) -> Result<Vec<ChangedPath>>;
fn diff_stat(revset: &RevsetExpr) -> Result<DiffStat>;
fn diff_text(spec: DiffSpec) -> Result<String>;
fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
fn commit_count(revset: &RevsetExpr) -> Result<usize>;
fn is_conflicted(revset: &RevsetExpr) -> Result<bool>;
fn has_workingcopy_conflict() -> Result<bool>;
fn resolve_list(revset: &RevsetExpr) -> Result<Vec<PathBuf>>;
fn template_query(revset: &RevsetExpr, template: &str, limit: Option<usize>) -> Result<String>;
fn description(revset: &RevsetExpr) -> Result<String>;
fn evolog(revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
fn file_annotate(path: &str, revset: Option<RevsetExpr>) -> Result<Vec<AnnotationLine>>;
fn file_show(revset: &RevsetExpr, path: &str) -> Result<String>;
fn absorb(from: Option<RevsetExpr>, filesets: &[JjFileset]) -> Result<()>;
fn split_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
fn duplicate(revset: &RevsetExpr) -> Result<()>;
fn rebase(onto: &RevsetExpr) -> Result<()>;
fn rebase_branch(branch: &RevsetExpr, dest: &RevsetExpr) -> Result<()>;
fn edit(revset: &RevsetExpr) -> Result<()>;
fn squash_into(spec: SquashInto) -> Result<()>;
fn commit_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
fn squash_paths(spec: SquashPaths) -> Result<()>;
fn sparse_set(patterns: &[String]) -> Result<()>;
fn new_merge(message: &str, parents: Vec<RevsetExpr>) -> Result<()>;
fn abandon(revset: &RevsetExpr) -> Result<()>;
fn git_fetch_branch(branch: &BookmarkName) -> Result<()>;
fn git_import() -> Result<()>;
fn op_head() -> Result<String>;
fn op_log(limit: usize) -> Result<Vec<Operation>>;
fn op_restore(op_id: &str) -> Result<()>;
fn op_undo() -> Result<()>;
fn workspace_list() -> Result<Vec<Workspace>>;
fn workspace_root(name: Option<String>) -> Result<PathBuf>;
fn workspace_add(spec: WorkspaceAdd) -> Result<()>;
fn workspace_forget(name: &str) -> Result<()>;
}
raw {
fn run(args: &[String]) -> Result<String> => run_in;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
fn run_args(args: &[&str]) -> Result<String> => run_args_in;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
}
}
impl<'a, R: ProcessRunner> JjAt<'a, R> {
pub async fn transaction<T, F, Fut>(&self, f: F) -> std::result::Result<T, TransactionError>
where
F: FnOnce(JjAt<'a, R>) -> Fut,
Fut: Future<Output = Result<T>> + 'a,
{
self.jj.transaction(self.dir, f).await
}
}
pub fn normalize_workspace_root(p: &Path) -> PathBuf {
let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
#[cfg(windows)]
{
return strip_windows_verbatim_prefix(canonical);
}
#[cfg(not(windows))]
canonical
}
#[cfg(windows)]
fn strip_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
let path = path.to_string_lossy();
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{rest}"))
} else if let Some(rest) = path.strip_prefix(r"\\?\") {
PathBuf::from(rest.to_string())
} else {
PathBuf::from(path.to_string())
}
}
pub fn workspace_root_matches(root: &Path, path: &Path) -> bool {
let target = normalize_workspace_root(path);
normalize_workspace_root(root) == target || root == target || root == path
}
pub mod blocking {
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn workspace_forget(dir: &Path, name: &str) -> std::io::Result<()> {
let status = Command::new(super::BINARY)
.current_dir(dir)
.args(["workspace", "forget", name])
.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::other(format!(
"`jj workspace forget` exited with {status}"
)))
}
}
pub fn workspace_name_for_path(dir: &Path, path: &Path) -> io::Result<Option<String>> {
let out = Command::new(super::BINARY)
.current_dir(dir)
.args([
"--ignore-working-copy",
"workspace",
"list",
"-T",
"name ++ \"\\n\"",
"--color",
"never",
])
.output()?;
if !out.status.success() {
return Err(io::Error::other(format!(
"`jj workspace list` exited with {} while resolving the workspace at {}",
out.status,
path.display(),
)));
}
let mut unresolved: Vec<String> = Vec::new();
for name in String::from_utf8_lossy(&out.stdout).lines() {
let name = name.trim();
if name.is_empty() {
continue;
}
let root = Command::new(super::BINARY)
.current_dir(dir)
.args([
"--ignore-working-copy",
"workspace",
"root",
"--name",
name,
"--color",
"never",
])
.output();
match root {
Ok(r) if r.status.success() => {
let p = PathBuf::from(String::from_utf8_lossy(&r.stdout).trim().to_string());
if super::workspace_root_matches(&p, path) {
return Ok(Some(name.to_string()));
}
}
_ => unresolved.push(name.to_string()),
}
}
if unresolved.is_empty() {
Ok(None)
} else {
Err(io::Error::other(format!(
"could not resolve the workspace at {}: {} registered workspace(s) did not \
resolve via `jj workspace root --name` ({}); resolve or `jj workspace forget` \
them manually",
path.display(),
unresolved.len(),
unresolved.join(", "),
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
fn rv(s: &str) -> RevsetExpr {
RevsetExpr::new(s).unwrap()
}
fn bn(s: &str) -> BookmarkName {
BookmarkName::new(s).unwrap()
}
#[test]
fn binary_name_is_jj() {
assert_eq!(BINARY, "jj");
}
#[test]
fn workspace_root_matches_unifies_the_comparison_set() {
use vcs_testkit::TempDir;
let tmp = TempDir::new("t080-workspace-root-matches");
let root = tmp.path().join("ws");
std::fs::create_dir_all(&root).unwrap();
let other = tmp.path().join("elsewhere");
std::fs::create_dir_all(&other).unwrap();
assert!(
workspace_root_matches(&root, &root),
"identical paths must match"
);
let detour = root.join(".").join("..").join(root.file_name().unwrap());
assert!(
workspace_root_matches(&root, &detour),
"a `.`/`..` detour resolving to the same directory must match"
);
assert!(
workspace_root_matches(&detour, &root),
"the match must be symmetric in which side carries the detour"
);
let missing = tmp.path().join("gone").join("ws");
assert!(
workspace_root_matches(&missing, &missing),
"a non-existent but literally-equal path/root pair must still match"
);
assert!(
!workspace_root_matches(&root, &other),
"distinct directories must not match"
);
assert!(
!workspace_root_matches(&root, &missing),
"an unrelated non-existent path must not match either"
);
}
#[cfg(windows)]
#[test]
fn verbatim_unc_paths_normalize_and_match_ordinary_unc_paths() {
let ordinary = Path::new(r"\\server\share\workspace");
let verbatim = Path::new(r"\\?\UNC\server\share\workspace");
assert_eq!(
strip_windows_verbatim_prefix(verbatim.to_path_buf()),
ordinary,
"a verbatim UNC path must become its ordinary UNC spelling"
);
assert_eq!(
normalize_workspace_root(verbatim),
normalize_workspace_root(ordinary)
);
assert!(
workspace_root_matches(verbatim, ordinary),
"a verbatim workspace root must match an ordinary requested path"
);
assert!(
workspace_root_matches(ordinary, verbatim),
"an ordinary workspace root must match a verbatim requested path"
);
}
#[allow(dead_code)]
fn bound_view_is_copy_for_default_runner() {
fn assert_copy<T: Copy>() {}
assert_copy::<JjAt<'static, processkit::JobRunner>>();
}
#[tokio::test]
async fn bound_view_matches_dir_taking_calls() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.bookmark_move(
dir,
BookmarkMove::new(bn("main"), rv("@")).allow_backwards(),
)
.await
.unwrap();
jj.at(dir)
.bookmark_move(BookmarkMove::new(bn("main"), rv("@")).allow_backwards())
.await
.unwrap();
jj.describe_rev(dir, &rv("feat"), "msg").await.unwrap();
jj.at(dir).describe_rev(&rv("feat"), "msg").await.unwrap();
jj.description(dir, &rv("@-")).await.unwrap();
jj.at(dir).description(&rv("@-")).await.unwrap();
jj.duplicate(dir, &rv("@-")).await.unwrap();
jj.at(dir).duplicate(&rv("@-")).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), calls[1].args_str());
assert_eq!(calls[2].args_str(), calls[3].args_str());
assert_eq!(calls[4].args_str(), calls[5].args_str());
assert_eq!(calls[6].args_str(), calls[7].args_str());
assert_eq!(calls[1].cwd.as_deref(), Some(dir));
}
#[tokio::test]
async fn bound_view_raw_hatch_runs_in_bound_dir() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.at(dir).run(&["status".to_string()]).await.unwrap();
let _ = jj.at(dir).run_raw(&["status".to_string()]).await.unwrap();
jj.at(dir).run_args(&["status"]).await.unwrap();
let _ = jj.at(dir).run_raw_args(&["status"]).await.unwrap();
jj.run(&["status".to_string()]).await.unwrap();
let _ = jj.run_raw(&["status".to_string()]).await.unwrap();
jj.run_args(&["status"]).await.unwrap();
let _ = jj.run_raw_args(&["status"]).await.unwrap();
let calls = rec.calls();
for c in &calls[0..4] {
assert_eq!(
c.cwd.as_deref(),
Some(dir),
"raw call through the bound view runs in the bound dir"
);
assert_eq!(c.args_str(), ["status"]);
}
for c in &calls[4..8] {
assert_eq!(
c.cwd.as_deref(),
None,
"raw call on the client stays in the process cwd"
);
assert_eq!(c.args_str(), ["status"]);
}
}
#[tokio::test]
async fn read_only_query_twins_append_ignore_working_copy() {
let dir = Path::new("/repo");
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.status(dir).await.unwrap();
jj.status_ignoring_working_copy(dir).await.unwrap();
jj.bookmarks(dir).await.unwrap();
jj.bookmarks_ignoring_working_copy(dir).await.unwrap();
jj.reachable_bookmarks(dir).await.unwrap();
jj.reachable_bookmarks_ignoring_working_copy(dir)
.await
.unwrap();
jj.template_query(dir, &rv("@"), "commit_id", Some(1))
.await
.unwrap();
jj.template_query_ignoring_working_copy(dir, &rv("@"), "commit_id", Some(1))
.await
.unwrap();
let calls = rec.calls();
assert_eq!(
calls.len(),
10,
"status's two-call pairs (root, diff) x2 forms, plus three single-call pairs"
);
let (status_calls, rest) = calls.split_at(4);
let (live, read_only) = status_calls.split_at(2);
for (live_call, read_only_call) in live.iter().zip(read_only) {
let live_args = live_call.args_str();
let read_only_args = read_only_call.args_str();
assert!(
!live_args.iter().any(|a| a == "--ignore-working-copy"),
"the default form must snapshot the working copy (no flag): {live_args:?}"
);
let mut expected = live_args.clone();
expected.push("--ignore-working-copy".to_string());
assert_eq!(
read_only_args, expected,
"status's read-only twin must be the default argv + --ignore-working-copy"
);
}
for pair in rest.chunks(2) {
let live = pair[0].args_str();
let read_only = pair[1].args_str();
assert!(
!live.iter().any(|a| a == "--ignore-working-copy"),
"the default form must snapshot the working copy (no flag): {live:?}"
);
let mut expected = live.clone();
expected.push("--ignore-working-copy".to_string());
assert_eq!(
read_only, expected,
"the read-only twin must be the default argv + --ignore-working-copy"
);
}
}
#[tokio::test]
async fn workspace_list_parses_template_rows() {
let jj = Jj::with_runner(ScriptedRunner::new().on(
["jj", "workspace", "list"],
Reply::ok("\"default\"\te2aa3420\t\"main\"\n\"ws1\"\t12345678\t\n"),
));
let got = jj.workspace_list(Path::new(".")).await.expect("list");
assert_eq!(got.len(), 2);
assert_eq!(got[0].name, "default");
assert_eq!(got[0].bookmarks, vec!["main".to_string()]);
assert!(got[1].bookmarks.is_empty());
}
#[tokio::test]
async fn workspace_roots_batches_per_name_and_maps_errors() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(
[
"jj",
"--ignore-working-copy",
"workspace",
"root",
"--name",
"default",
],
Reply::ok("/repo\n"),
)
.on(
[
"jj",
"--ignore-working-copy",
"workspace",
"root",
"--name",
"ws1",
],
Reply::ok("/repo/ws1\n"),
)
.on(
[
"jj",
"--ignore-working-copy",
"workspace",
"root",
"--name",
"gone",
],
Reply::fail(1, "Error: No such workspace"),
),
);
let jj = Jj::with_runner(&rec);
let roots = jj
.workspace_roots(
Path::new("/repo"),
&["default".into(), "gone".into(), "ws1".into()],
)
.await;
assert_eq!(roots.len(), 3);
assert_eq!(roots[0].as_deref().unwrap(), Path::new("/repo"));
assert!(roots[1].is_err(), "a non-zero `workspace root` is Err");
assert_eq!(roots[2].as_deref().unwrap(), Path::new("/repo/ws1"));
let calls = rec.calls();
assert_eq!(calls.len(), 3);
assert!(
calls
.iter()
.all(|c| c.args_str()[..3] == ["--ignore-working-copy", "workspace", "root"])
);
}
#[tokio::test]
async fn workspace_add_builds_name_base_path() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.workspace_add(
Path::new("/repo"),
WorkspaceAdd::new("ws1", rv("main"), "/wt"),
)
.await
.expect("workspace add");
assert_eq!(
rec.only_call().args_str(),
[
"workspace",
"add",
"--name",
"ws1",
"-r",
"main",
"/wt",
"--color",
"never"
]
);
}
#[tokio::test]
async fn workspace_add_with_sparse_mode() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.workspace_add(
Path::new("/repo"),
WorkspaceAdd::new("ws1", rv("main"), "/wt").sparse(SparseMode::Empty),
)
.await
.expect("workspace add");
assert_eq!(
rec.only_call().args_str(),
[
"workspace",
"add",
"--name",
"ws1",
"-r",
"main",
"--sparse-patterns",
"empty",
"/wt",
"--color",
"never"
]
);
}
#[test]
fn fileset_quotes_metacharacters() {
assert_eq!(
JjFileset::path("src/a(b).rs").as_str(),
"root-file:\"src/a(b).rs\""
);
}
#[test]
fn fileset_escapes_double_quote() {
assert_eq!(JjFileset::path("a\"b").as_str(), "root-file:\"a\\\"b\"");
}
#[test]
fn fileset_is_workspace_root_relative() {
assert!(
JjFileset::path("src/a.rs")
.as_str()
.starts_with("root-file:\"")
);
assert!(!JjFileset::path("src/a.rs").as_str().starts_with("file:"));
}
#[test]
#[cfg(windows)]
fn fileset_normalises_backslash_on_windows() {
assert_eq!(
JjFileset::path("src\\a.rs").as_str(),
"root-file:\"src/a.rs\""
);
}
#[test]
#[cfg(not(windows))]
fn fileset_escapes_backslashes_on_unix() {
assert_eq!(
JjFileset::path("a\\b.txt").as_str(),
"root-file:\"a\\\\b.txt\""
);
assert_eq!(JjFileset::path("a\\").as_str(), "root-file:\"a\\\\\"");
assert_eq!(
JjFileset::path("a\\b\"c.txt").as_str(),
"root-file:\"a\\\\b\\\"c.txt\""
);
}
#[tokio::test]
async fn commit_paths_builds_filesets() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.commit_paths(
Path::new("."),
&[JjFileset::path("x|y.rs"), JjFileset::path("z.rs")],
"msg",
)
.await
.expect("commit_paths");
assert_eq!(
rec.only_call().args_str(),
[
"commit",
"-m",
"msg",
"root-file:\"x|y.rs\"",
"root-file:\"z.rs\"",
"--color",
"never"
]
);
}
#[tokio::test]
async fn squash_paths_builds_from_into_filesets() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.squash_paths(
Path::new("."),
SquashPaths::new(rv("@"), rv("feat")).filesets([JjFileset::path("a.rs")]),
)
.await
.expect("squash_paths");
assert_eq!(
rec.only_call().args_str(),
[
"squash",
"--from",
"@",
"--into",
"feat",
"root-file:\"a.rs\"",
"--color",
"never"
]
);
}
#[tokio::test]
async fn squash_paths_keeps_destination_message() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.squash_paths(
Path::new("."),
SquashPaths::new(rv("@"), rv("feat"))
.filesets([JjFileset::path("a.rs")])
.use_destination_message(),
)
.await
.expect("squash_paths");
assert_eq!(
rec.only_call().args_str(),
[
"squash",
"--from",
"@",
"--into",
"feat",
"--use-destination-message",
"root-file:\"a.rs\"",
"--color",
"never"
]
);
}
#[tokio::test]
async fn jj_new_revision_scoped_ops_build_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.describe_rev(Path::new("."), &rv("feat"), "msg")
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["describe", "-r", "feat", "-m", "msg", "--color", "never"]
);
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.rebase_branch(Path::new("."), &rv("feat"), &rv("main"))
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["rebase", "-b", "feat", "-d", "main", "--color", "never"]
);
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.bookmark_track(Path::new("."), &bn("feat"), "origin")
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["bookmark", "track", "exact:feat@origin", "--color", "never"]
);
}
#[tokio::test]
async fn bookmark_track_rejects_glob_like_remote() {
for remote in ["*", "o?igin", "[origin]"] {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
assert!(
jj.bookmark_track(Path::new("."), &bn("main"), remote)
.await
.is_err(),
"remote {remote:?} should be rejected before spawn"
);
assert!(
rec.calls().is_empty(),
"must not spawn for remote {remote:?}"
);
}
}
#[tokio::test]
async fn bookmarks_uses_template_and_parses_rows() {
let rec = RecordingRunner::replying(Reply::ok(
"1\t\t\"main\"\tabc123\n1\t\t\"feature\"\tdef456\n",
));
let jj = Jj::with_runner(&rec);
let marks = jj.bookmarks(Path::new(".")).await.unwrap();
assert_eq!(
rec.only_call().args_str(),
[
"bookmark",
"list",
"-T",
parse::BOOKMARK_LIST_TEMPLATE,
"--color",
"never"
]
);
assert_eq!(marks.len(), 2);
assert_eq!(marks[0].name, "main");
assert_eq!(marks[0].target, "abc123");
assert_eq!(marks[1].name, "feature");
}
#[tokio::test]
async fn bookmarks_all_parses_local_and_remote() {
let jj = Jj::with_runner(ScriptedRunner::new().on(
["jj", "bookmark", "list"],
Reply::ok("1\t\"main\"\t\t0\tabc123\n1\t\"main\"\torigin\t1\tabc123\n"),
));
let refs = jj.bookmarks_all(Path::new(".")).await.unwrap();
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].name, "main");
assert!(refs[0].remote.is_none() && !refs[0].tracked);
assert_eq!(refs[1].remote.as_deref(), Some("origin"));
assert!(refs[1].tracked);
}
#[tokio::test]
async fn sparse_set_clears_then_adds() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.sparse_set(Path::new("."), &["README.md".into(), "lib".into()])
.await
.expect("sparse_set");
assert_eq!(
rec.only_call().args_str(),
[
"sparse",
"set",
"--clear",
"--add",
"README.md",
"--add",
"lib",
"--color",
"never"
]
);
}
#[tokio::test]
async fn status_parses_diff_summary() {
let jj = Jj::with_runner(
ScriptedRunner::new()
.on(["jj", "root"], Reply::ok("/repo\n"))
.on(
["jj", "diff", "-r", "@", "--summary"],
Reply::ok("M a.rs\nA b.rs\n"),
),
);
let entries = jj.status(Path::new(".")).await.expect("status");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].status, 'M');
assert_eq!(entries[1].path, Path::new("b.rs"));
}
#[test]
fn summary_paths_normalise_windows_and_reject_workspace_escapes() {
let paths = normalize_changed_paths(vec![ChangedPath {
status: 'R',
path: "src\\.\\new.rs".into(),
old_path: Some("src\\old.rs".into()),
}])
.expect("normalise");
assert_eq!(paths[0].path, Path::new("src/new.rs"));
assert_eq!(paths[0].old_path.as_deref(), Some(Path::new("src/old.rs")));
let err = normalize_changed_paths(vec![ChangedPath {
status: 'M',
path: "../outside.rs".into(),
old_path: None,
}])
.expect_err("must reject a path outside the workspace");
assert!(err.to_string().contains("escapes the workspace root"));
}
#[tokio::test]
async fn status_text_is_raw_jj_status() {
let jj = Jj::with_runner(
ScriptedRunner::new().on(["jj", "status"], Reply::ok("Working copy changes:\n")),
);
assert!(
jj.status_text(Path::new("."))
.await
.expect("status_text")
.contains("Working copy changes")
);
}
#[tokio::test]
async fn run_args_forwards_str_slices() {
let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "root"], Reply::ok("/r\n")));
assert_eq!(jj.run_args(&["root"]).await.unwrap(), "/r");
}
#[tokio::test]
async fn bookmark_move_appends_allow_backwards() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.bookmark_move(
Path::new("/r"),
BookmarkMove::new(bn("main"), rv("@")).allow_backwards(),
)
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
[
"bookmark",
"move",
"exact:main",
"--to",
"@",
"--allow-backwards",
"--color",
"never"
]
);
}
#[tokio::test]
async fn bookmark_move_default_omits_allow_backwards() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.bookmark_move(Path::new("/r"), BookmarkMove::new(bn("main"), rv("@")))
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
[
"bookmark",
"move",
"exact:main",
"--to",
"@",
"--color",
"never"
]
);
}
#[tokio::test]
async fn squash_into_builds_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.squash_into(Path::new("/r"), SquashInto::new(rv("@-")))
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["squash", "--into", "@-", "--color", "never"]
);
let flagged = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&flagged);
jj.squash_into(
Path::new("/r"),
SquashInto::new(rv("@-")).use_destination_message(),
)
.await
.unwrap();
assert_eq!(
flagged.only_call().args_str(),
[
"squash",
"--into",
"@-",
"--color",
"never",
"--use-destination-message"
]
);
}
#[tokio::test]
async fn new_merge_appends_parents() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.new_merge(Path::new("/r"), "m", vec![rv("p1"), rv("p2")])
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["new", "-m", "m", "p1", "p2", "--color", "never"]
);
}
#[tokio::test]
async fn is_conflicted_reads_template_flag() {
let yes = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
assert!(yes.is_conflicted(Path::new("."), &rv("@")).await.unwrap());
let no = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
assert!(!no.is_conflicted(Path::new("."), &rv("@")).await.unwrap());
}
#[tokio::test]
async fn commit_count_counts_template_lines() {
let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("a\nb\nc\n")));
assert_eq!(
jj.commit_count(Path::new("."), &rv("::@")).await.unwrap(),
3
);
}
#[tokio::test]
async fn reachable_bookmarks_queries_heads_revset() {
let rec = RecordingRunner::replying(Reply::ok("\"main\"\tabc123\n"));
let jj = Jj::with_runner(&rec);
let got = jj.reachable_bookmarks(Path::new(".")).await.unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].name, "main");
let args = rec.only_call().args_str();
assert_eq!(
&args[..4],
&["log", "-r", "heads(::@ & bookmarks())", "--no-graph"]
);
}
#[tokio::test]
async fn resolve_list_distinguishes_no_conflicts_from_errors() {
let none = Jj::with_runner(ScriptedRunner::new().on(
["jj", "resolve"],
Reply::fail(2, "Error: No conflicts found at this revision"),
));
assert!(
none.resolve_list(Path::new("."), &rv("@"))
.await
.unwrap()
.is_empty()
);
let bad = Jj::with_runner(ScriptedRunner::new().on(
["jj", "resolve"],
Reply::fail(1, "Error: Revision `bogus` doesn't exist"),
));
assert!(
bad.resolve_list(Path::new("."), &rv("bogus"))
.await
.is_err()
);
let some = Jj::with_runner(
ScriptedRunner::new().on(["jj", "resolve"], Reply::ok("a.rs 2-sided conflict\n")),
);
assert_eq!(
some.resolve_list(Path::new("."), &rv("@")).await.unwrap(),
[PathBuf::from("a.rs")]
);
}
#[tokio::test]
async fn current_bookmark_takes_first_or_none() {
let some =
Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\"main\"\n")));
assert_eq!(
some.current_bookmark(Path::new("."))
.await
.unwrap()
.as_deref(),
Some("main")
);
let none = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\n")));
assert!(
none.current_bookmark(Path::new("."))
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn current_change_parses_scripted_output() {
let jj = Jj::with_runner(ScriptedRunner::new().on(
["jj", "log"],
Reply::ok("kztuxlro\t38e00654\tfalse\t\"hello jj\"\n"),
));
let change = jj
.current_change(Path::new("."))
.await
.expect("current_change");
assert_eq!(change.change_id, "kztuxlro");
assert!(!change.empty);
assert_eq!(change.description, "hello jj");
}
#[tokio::test]
async fn git_push_appends_bookmark_flag() {
let jj = Jj::with_runner(
ScriptedRunner::new().on(["jj", "git", "push", "-b", "exact:feature"], Reply::ok("")),
);
jj.git_push(Path::new("."), Some(bn("feature")))
.await
.expect("should build `git push -b exact:feature`");
}
#[tokio::test]
async fn git_push_without_bookmark_is_bare() {
let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "git", "push"], Reply::ok("")));
jj.git_push(Path::new("."), None).await.expect("bare push");
}
#[tokio::test]
async fn bookmark_delete_and_fetch_branch_use_exact() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.bookmark_delete(Path::new("."), &bn("foo"))
.await
.unwrap();
assert_eq!(
&rec.only_call().args_str()[..3],
&["bookmark", "delete", "exact:foo"]
);
let rec2 = RecordingRunner::replying(Reply::ok(""));
let jj2 = Jj::with_runner(&rec2);
jj2.git_fetch_branch(Path::new("."), &bn("foo"))
.await
.unwrap();
assert_eq!(
&rec2.only_call().args_str()[..6],
&["git", "fetch", "--remote", "origin", "-b", "exact:foo"]
);
assert!(
rec2.only_call().envs.iter().any(|(k, v)| {
k.to_str() == Some("LC_ALL") && v.as_deref().and_then(|s| s.to_str()) == Some("C")
}),
"git_fetch_branch must pin LC_ALL=C"
);
}
#[tokio::test]
async fn git_fetch_retries_transient_failures() {
let rec = RecordingRunner::replying(Reply::fail(1, "Error: Could not resolve host: x"));
let jj = Jj::with_runner(&rec);
assert!(jj.git_fetch(Path::new(".")).await.is_err());
assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
assert!(
rec.calls()[0].envs.iter().any(|(k, v)| {
k.to_str() == Some("LC_ALL") && v.as_deref().and_then(|s| s.to_str()) == Some("C")
}),
"git fetch must pin LC_ALL=C"
);
}
#[tokio::test]
async fn with_retry_retries_lock_contention_on_a_mutation() {
let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
["jj", "abandon"],
[
Reply::fail(1, "Error: Failed to lock working copy"),
Reply::ok(""),
],
));
let jj = Jj::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
jj.abandon(Path::new("."), &rv("@-"))
.await
.expect("retried past the lock");
assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
["jj", "abandon"],
[
Reply::fail(1, "Error: Failed to lock working copy"),
Reply::ok(""),
],
));
let jj = Jj::with_runner(&rec);
assert!(jj.abandon(Path::new("."), &rv("@-")).await.is_err());
assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
}
#[tokio::test]
async fn git_fetch_from_builds_args_and_retries() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.git_fetch_from(Path::new("."), "upstream")
.await
.expect("git_fetch_from");
assert_eq!(
rec.only_call().args_str(),
[
"git",
"fetch",
"--remote",
"exact:upstream",
"--color",
"never"
]
);
assert!(
rec.only_call().envs.iter().any(|(k, v)| {
k.to_str() == Some("LC_ALL") && v.as_deref().and_then(|s| s.to_str()) == Some("C")
}),
"git_fetch_from must pin LC_ALL=C"
);
let failing = RecordingRunner::replying(Reply::fail(1, "Error: Connection timed out"));
let jj = Jj::with_runner(&failing);
assert!(jj.git_fetch_from(Path::new("."), "upstream").await.is_err());
assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
}
#[tokio::test]
async fn transaction_restores_op_head_on_error() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on_sequence(
["jj", "op", "log"],
[
Reply::ok("abc123\n"), Reply::ok("def456\t1\nabc123\t1\n"), ],
)
.on(["jj", "op", "restore"], Reply::ok(""))
.on(["jj", "describe"], Reply::fail(1, "boom")),
);
let jj = Jj::with_runner(&rec);
let res = jj
.transaction(
Path::new("/r"),
|tx| async move { tx.describe("wip").await },
)
.await;
let err = res.expect_err("closure error must surface");
assert!(
matches!(err.cause, Error::Exit { .. }),
"cause: {:?}",
err.cause
);
assert!(
matches!(err.rollback, Rollback::Restored),
"rollback: {:?}",
err.rollback
);
let calls = rec.calls();
assert_eq!(
calls.len(),
4,
"capture, mutation, divergence probe, restore: {calls:?}"
);
assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
assert_eq!(calls[1].args_str()[0], "describe");
assert_eq!(calls[2].args_str()[..2], ["op", "log"]);
assert!(
calls[2]
.args_str()
.iter()
.any(|a| a == "--ignore-working-copy"),
"the divergence probe must not snapshot the working copy: {:?}",
calls[2].args_str()
);
assert_eq!(calls[3].args_str()[..3], ["op", "restore", "abc123"]);
}
#[tokio::test]
async fn transaction_keeps_changes_on_success() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["jj", "op", "log"], Reply::ok("abc123\n"))
.on(["jj", "describe"], Reply::ok("")),
);
let jj = Jj::with_runner(&rec);
jj.transaction(
Path::new("/r"),
|tx| async move { tx.describe("wip").await },
)
.await
.expect("transaction");
let calls = rec.calls();
assert_eq!(calls.len(), 2, "capture + mutation only: {calls:?}");
assert!(
calls.iter().all(|c| c.args_str()[..2] != ["op", "restore"]),
"no restore on success: {calls:?}"
);
}
#[tokio::test]
async fn bound_view_forwards_transaction() {
let dir = Path::new("/repo");
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["jj", "op", "log"], Reply::ok("op9\n"))
.on(["jj", "new"], Reply::ok("")),
);
let jj = Jj::with_runner(&rec);
jj.at(dir)
.transaction(|tx| async move { tx.new_change("x").await })
.await
.expect("transaction");
assert_eq!(rec.calls()[1].cwd.as_deref(), Some(dir));
}
#[tokio::test]
async fn rollback_to_survives_fired_cancellation() {
let token = CancellationToken::new();
token.cancel(); let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["jj", "op", "log"], Reply::ok("post\t1\npre\t1\n"))
.on(["jj", "op", "restore"], Reply::ok("")),
);
let jj = Jj::with_runner(&rec).default_cancel_on(token);
let dir = Path::new("/r");
let outcome = jj.rollback_to(dir, "pre").await;
assert!(
matches!(outcome, Rollback::Restored),
"rollback must run despite the fired token: {outcome:?}"
);
assert!(
rec.calls()
.iter()
.any(|c| c.args_str()[..2] == ["op", "restore"]),
"the detached restore must have run: {:?}",
rec.calls()
);
let bare = jj.op_restore(dir, "pre").await;
assert!(
bare.as_ref().is_err_and(|e| e.is_cancelled()),
"a bare op_restore must inherit the fired token: {bare:?}"
);
}
#[tokio::test]
async fn transaction_rolls_back_after_cancelled_closure() {
let token = CancellationToken::new();
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on_sequence(
["jj", "op", "log"],
[
Reply::ok("pre\n"), Reply::ok("post\t1\npre\t1\n"), ],
)
.on(["jj", "op", "restore"], Reply::ok("")),
);
let jj = Jj::with_runner(&rec).default_cancel_on(token.clone());
let dir = Path::new("/r");
let res = jj
.transaction(dir, |_tx| async move {
token.cancel();
Err::<(), _>(Error::Cancelled {
program: "jj".to_string(),
})
})
.await;
let err = res.expect_err("cancelled closure");
assert!(err.cause.is_cancelled(), "cause: {:?}", err.cause);
assert!(
matches!(err.rollback, Rollback::Restored),
"the cleanup must survive the fired token: {:?}",
err.rollback
);
assert!(
rec.calls()
.iter()
.any(|c| c.args_str()[..2] == ["op", "restore"]),
"restore must have run: {:?}",
rec.calls()
);
}
#[tokio::test]
async fn transaction_reports_restore_failure() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on_sequence(
["jj", "op", "log"],
[Reply::ok("pre\n"), Reply::ok("post\t1\npre\t1\n")],
)
.on(["jj", "op", "restore"], Reply::fail(1, "op not found"))
.on(["jj", "describe"], Reply::fail(1, "boom")),
);
let jj = Jj::with_runner(&rec);
let res = jj
.transaction(
Path::new("/r"),
|tx| async move { tx.describe("wip").await },
)
.await;
let err = res.expect_err("closure error");
assert!(
matches!(err.cause, Error::Exit { .. }),
"cause: {:?}",
err.cause
);
match err.rollback {
Rollback::Failed(e) => {
assert!(matches!(e, Error::Exit { .. }), "rollback error: {e:?}");
}
other => panic!("expected Rollback::Failed, got {other:?}"),
}
}
#[tokio::test]
async fn transaction_skips_rollback_on_concurrent_divergence() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on_sequence(
["jj", "op", "log"],
[
Reply::ok("pre\n"),
Reply::ok("merge\t2\nmine\t1\npre\t1\n"),
],
)
.on(["jj", "op", "restore"], Reply::ok(""))
.on(["jj", "describe"], Reply::fail(1, "boom")),
);
let jj = Jj::with_runner(&rec);
let res = jj
.transaction(
Path::new("/r"),
|tx| async move { tx.describe("wip").await },
)
.await;
let err = res.expect_err("closure error");
assert!(
matches!(err.cause, Error::Exit { .. }),
"cause: {:?}",
err.cause
);
assert!(
matches!(err.rollback, Rollback::SkippedDiverged),
"rollback: {:?}",
err.rollback
);
assert!(
rec.calls()
.iter()
.all(|c| c.args_str()[..2] != ["op", "restore"]),
"must not revert across a divergence: {:?}",
rec.calls()
);
}
#[tokio::test]
async fn rollback_to_refuses_when_pre_not_in_window() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["jj", "op", "log"], Reply::ok("a\t1\nb\t1\nc\t1\n"))
.on(["jj", "op", "restore"], Reply::ok("")),
);
let jj = Jj::with_runner(&rec);
let outcome = jj.rollback_to(Path::new("/r"), "pre").await;
assert!(
matches!(outcome, Rollback::SkippedDiverged),
"unverifiable range must be refused: {outcome:?}"
);
assert!(
rec.calls()
.iter()
.all(|c| c.args_str()[..2] != ["op", "restore"]),
"no restore when the savepoint can't be located: {:?}",
rec.calls()
);
}
#[test]
fn validated_bookmark_and_revset_newtypes_reject_bad_values() {
for bad in ["", "-evil", "--all", "-bad", "--config=x", "-r"] {
let b = BookmarkName::new(bad).expect_err("bookmark name must be rejected");
assert!(vcs_cli_support::is_invalid_input(&b), "bookmark {bad:?}");
let r = RevsetExpr::new(bad).expect_err("revset must be rejected");
assert!(vcs_cli_support::is_invalid_input(&r), "revset {bad:?}");
}
assert!(BookmarkName::new("feature/x").is_ok());
assert!(RevsetExpr::new("heads(::@ & bookmarks())").is_ok());
}
#[tokio::test]
async fn str_positionals_are_rejected_before_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
let dir = Path::new("/r");
assert!(jj.op_restore(dir, "--help").await.is_err());
assert!(jj.workspace_forget(dir, "-evil").await.is_err());
assert!(
jj.git_clone("-evil", dir, GitClone::separate())
.await
.is_err()
);
assert!(
rec.calls().is_empty(),
"nothing may spawn: {:?}",
rec.calls()
);
}
#[tokio::test]
async fn typed_edit_passes_through() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.edit(Path::new("/r"), &rv("abc123")).await.expect("edit");
assert_eq!(
rec.only_call().args_str(),
["edit", "abc123", "--color", "never"]
);
}
#[test]
fn revset_expr_validates() {
assert!(RevsetExpr::new("heads(::@ & bookmarks())").is_ok());
assert_eq!(RevsetExpr::new("@-").unwrap().as_str(), "@-");
assert!(RevsetExpr::new("-evil").is_err());
assert!(RevsetExpr::new("").is_err());
}
#[tokio::test]
async fn capabilities_parse_and_gate_versions() {
let jj = Jj::with_runner(
ScriptedRunner::new().on(["jj", "--version"], Reply::ok("jj 0.38.0\n")),
);
let caps = jj.capabilities().await.expect("capabilities");
assert!(caps.is_supported());
caps.ensure_supported().expect("supported");
let dev = Jj::with_runner(
ScriptedRunner::new().on(["jj", "--version"], Reply::ok("jj 0.39.0-dev+abc123\n")),
);
assert!(dev.capabilities().await.unwrap().is_supported());
let old = Jj::with_runner(
ScriptedRunner::new().on(["jj", "--version"], Reply::ok("jj 0.35.0\n")),
);
let caps = old.capabilities().await.expect("capabilities");
assert!(!caps.is_supported());
let err = caps.ensure_supported().expect_err("unsupported");
let Error::Spawn { source, .. } = &err else {
panic!("expected Spawn, got {err:?}");
};
let message = source.to_string();
assert!(message.contains("0.38.0"), "names the floor: {message}");
assert!(
message.contains("0.35.0"),
"names the found version: {message}"
);
let garbage =
Jj::with_runner(ScriptedRunner::new().on(["jj", "--version"], Reply::ok("nope")));
assert!(matches!(
garbage.capabilities().await.unwrap_err(),
Error::Parse { .. }
));
}
#[tokio::test]
async fn git_clone_builds_dirless_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.git_clone("https://x/r.git", Path::new("/dest"), GitClone::colocated())
.await
.expect("clone");
let call = rec.only_call();
assert_eq!(
call.args_str(),
[
"git",
"clone",
"https://x/r.git",
"/dest",
"--colocate",
"--color",
"never"
]
);
assert_eq!(call.cwd, None, "clone runs without a working directory");
let plain = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&plain);
jj.git_clone("u", Path::new("/d"), GitClone::separate())
.await
.unwrap();
let call = plain.only_call();
assert!(call.has_flag("--no-colocate"), "explicit either way");
assert!(!call.has_flag("--colocate"));
}
#[tokio::test]
async fn git_clone_failure_cleans_only_a_dest_it_could_have_created() {
use vcs_testkit::TempDir;
let tmp = TempDir::new("r7-jj-clone");
let jj = Jj::with_runner(ScriptedRunner::new().on(
["jj", "git", "clone"],
Reply::fail(1, "Error: fetch failed"),
));
let occupied = tmp.path().join("occupied");
std::fs::create_dir(&occupied).unwrap();
std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
assert!(
jj.git_clone("https://x/r", &occupied, GitClone::separate())
.await
.is_err()
);
assert!(
occupied.join("keep.txt").exists(),
"a non-empty caller dir must survive a failed jj clone"
);
let empty = tmp.path().join("empty");
std::fs::create_dir(&empty).unwrap();
assert!(
jj.git_clone("https://x/r", &empty, GitClone::separate())
.await
.is_err()
);
assert!(
!empty.exists(),
"an empty dest is cleaned so a retry isn't blocked"
);
}
#[tokio::test]
async fn absorb_and_split_build_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.absorb(Path::new("/r"), None, &[]).await.unwrap();
jj.absorb(
Path::new("/r"),
Some(rv("@-")),
&[JjFileset::path("src/a.rs")],
)
.await
.unwrap();
jj.split_paths(Path::new("/r"), &[JjFileset::path("b.rs")], "split out b")
.await
.unwrap();
jj.duplicate(Path::new("/r"), &rv("@-")).await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["absorb", "--color", "never"]);
assert_eq!(
calls[1].args_str(),
[
"absorb",
"--from",
"@-",
"root-file:\"src/a.rs\"",
"--color",
"never"
]
);
assert_eq!(
calls[2].args_str(),
[
"split",
"-m",
"split out b",
"root-file:\"b.rs\"",
"--color",
"never"
]
);
assert_eq!(calls[3].args_str(), ["duplicate", "@-", "--color", "never"]);
}
#[tokio::test]
async fn split_paths_refuses_empty_filesets_without_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
let err = jj
.split_paths(Path::new("/r"), &[], "msg")
.await
.expect_err("empty filesets must be refused");
assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[tokio::test]
async fn commit_paths_refuses_empty_filesets_without_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
let err = jj
.commit_paths(Path::new("/r"), &[], "msg")
.await
.expect_err("empty filesets must be refused");
assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[tokio::test]
async fn log_paths_builds_revset_template_and_filesets() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.log_paths(
Path::new("."),
&rv("main..@"),
5,
&[JjFileset::path("x|y.rs"), JjFileset::path("z.rs")],
)
.await
.expect("log_paths");
assert_eq!(
rec.only_call().args_str(),
[
"log",
"-r",
"main..@",
"-n5",
"--no-graph",
"-T",
parse::CHANGE_TEMPLATE,
"root-file:\"x|y.rs\"",
"root-file:\"z.rs\"",
"--color",
"never"
]
);
}
#[tokio::test]
async fn log_paths_refuses_empty_filesets_without_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
let err = jj
.log_paths(Path::new("."), &rv("@"), 5, &[])
.await
.expect_err("empty filesets must be refused");
assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
assert!(rec.calls().is_empty(), "nothing may spawn");
}
#[tokio::test]
async fn op_log_parses_template_rows() {
let rec = RecordingRunner::new(ScriptedRunner::new().on(
["jj", "op", "log"],
Reply::ok("abc\t\"u@h\"\t2026-06-05T10:00:00+0200\t\"new empty commit\"\n"),
));
let jj = Jj::with_runner(&rec);
let ops = jj.op_log(Path::new("."), 5).await.expect("op_log");
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].id, "abc");
assert_eq!(ops[0].description, "new empty commit");
let args = rec.only_call().args_str();
assert_eq!(&args[..5], &["op", "log", "--no-graph", "--limit", "5"]);
}
#[tokio::test]
async fn evolog_uses_commit_context_template() {
let rec = RecordingRunner::new(
ScriptedRunner::new().on(["jj", "evolog"], Reply::ok("kz\t38\tfalse\t\"wip\"\n")),
);
let jj = Jj::with_runner(&rec);
let rows = jj
.evolog(Path::new("."), &rv("@"), 10)
.await
.expect("evolog");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].description, "wip");
let args = rec.only_call().args_str();
assert_eq!(
&args[..6],
&["evolog", "-r", "@", "--no-graph", "--limit", "10"]
);
let template = &args[7];
assert!(
template.contains("commit.change_id()"),
"commit-context form required, got {template}"
);
}
#[tokio::test]
async fn file_annotate_and_show_build_args() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(
["jj", "file", "annotate"],
Reply::ok("kz\tline one\nkz\tline two"),
)
.on(["jj", "file", "show"], Reply::ok("content\n")),
);
let jj = Jj::with_runner(&rec);
let lines = jj
.file_annotate(Path::new("."), "src/a.rs", Some(rv("@-")))
.await
.expect("annotate");
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].change_id, "kz");
assert_eq!(lines[1].line, 2);
assert_eq!(
jj.file_show(Path::new("."), &rv("@-"), "src/a.rs")
.await
.unwrap(),
"content\n"
);
let calls = rec.calls();
assert_eq!(
calls[0].args_str(),
[
"file",
"annotate",
"-r",
"@-",
"-T",
parse::ANNOTATE_TEMPLATE,
"--color",
"never",
"--",
"src/a.rs"
]
);
assert_eq!(
calls[1].args_str(),
[
"file",
"show",
"-r",
"@-",
"root-file:\"src/a.rs\"",
"--color",
"never"
]
);
}
#[tokio::test]
async fn description_builds_single_commit_template_query() {
let rec = RecordingRunner::replying(Reply::ok("feat: parser\n\nbody\n"));
let jj = Jj::with_runner(&rec);
let text = jj
.description(Path::new("."), &rv("abc123"))
.await
.expect("description");
assert_eq!(text, "feat: parser\n\nbody");
assert_eq!(
rec.only_call().args_str(),
[
"log",
"-r",
"abc123",
"--no-graph",
"--limit",
"1",
"-T",
"description",
"--color",
"never"
]
);
}
#[tokio::test]
async fn content_verbs_preserve_exact_trailing_bytes() {
for raw in ["a\nb\n\n", "no-final-newline", "trailing \n"] {
let rec = RecordingRunner::replying(Reply::ok(raw));
let jj = Jj::with_runner(&rec);
assert_eq!(
jj.file_show(Path::new("."), &rv("@"), "f.txt")
.await
.expect("file_show"),
raw
);
}
let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
let rec = RecordingRunner::replying(Reply::ok(diff));
let jj = Jj::with_runner(&rec);
assert_eq!(
jj.diff_text(Path::new("."), DiffSpec::Rev("@".into()))
.await
.expect("diff_text"),
diff
);
}
#[tokio::test]
async fn diff_text_builds_working_copy_args() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
jj.diff_text(Path::new("."), DiffSpec::WorkingTree)
.await
.expect("diff_text");
assert_eq!(
rec.only_call().args_str(),
["diff", "-r", "@", "--git", "--color", "never"]
);
}
#[tokio::test]
async fn commands_force_color_off() {
let rec = RecordingRunner::replying(Reply::ok("x\n"));
let jj = Jj::with_runner(&rec);
jj.status_text(Path::new(".")).await.expect("status_text");
let args = rec.only_call().args_str();
let pos = args.iter().position(|a| a == "--color");
assert_eq!(
pos.map(|p| args.get(p + 1).map(String::as_str)),
Some(Some("never"))
);
}
#[tokio::test]
async fn diff_parses_scripted_output() {
let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "diff"], Reply::ok(out)));
let files = jj
.diff(Path::new("."), DiffSpec::Rev("@-".into()))
.await
.expect("diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, Path::new("m"));
assert_eq!(files[0].change, ChangeKind::Modified);
}
#[tokio::test]
async fn diff_text_over_budget_errors_output_too_large() {
let big = "diff --git a/m b/m\n".to_string() + &"+line\n".repeat(20_000);
assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "diff"], Reply::ok(&big)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
match jj
.diff_text(Path::new("."), DiffSpec::Rev("@-".into()))
.await
{
Err(Error::OutputTooLarge {
program,
max_bytes,
total_bytes,
..
}) => {
assert_eq!(program, "jj");
assert_eq!(max_bytes, Some(64 * 1024));
assert!(total_bytes > 64 * 1024, "actual exceeds allowed");
}
other => panic!("expected OutputTooLarge, got {other:?}"),
}
}
#[tokio::test]
async fn diff_under_budget_parses_full_output() {
let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "diff"], Reply::ok(out)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
let files = jj
.diff(Path::new("."), DiffSpec::Rev("@-".into()))
.await
.expect("under-budget diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, Path::new("m"));
}
#[tokio::test]
async fn file_show_over_budget_errors_and_override_reads() {
let big = "x".repeat(200_000);
let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "file", "show"], Reply::ok(&big)))
.default_output_budget(OutputBudget::bytes(64 * 1024));
assert!(matches!(
jj.file_show(Path::new("."), &rv("@"), "big.bin").await,
Err(Error::OutputTooLarge { .. })
));
let got = jj
.file_show_within(
Path::new("."),
&rv("@"),
"big.bin",
OutputBudget::unlimited(),
)
.await
.expect("override reads the large file");
assert_eq!(got, big);
}
#[cfg(feature = "mock")]
#[tokio::test]
async fn consumer_mocks_the_interface() {
let mut mock = MockJjApi::new();
mock.expect_describe().returning(|_, _| Ok(()));
assert!(mock.describe(Path::new("."), "msg").await.is_ok());
}
}
#[doc = include_str!("../docs/jj.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {}