#![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;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
#[cfg(feature = "cancellation")]
#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
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, DiffStat, FileDiff, Hunk, Version as JjVersion, parse_diff,
};
pub use vcs_cli_support::is_transient_fetch_error;
pub const BINARY: &str = "jj";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DiffSpec {
WorkingTree,
Rev(String),
}
#[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 escaped = path.as_ref().replace('\\', "/").replace('"', "\\\"");
JjFileset(format!("file:\"{escaped}\""))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorkspaceAdd {
pub name: String,
pub base: String,
pub path: PathBuf,
pub sparse_patterns: Option<SparseMode>,
}
impl WorkspaceAdd {
pub fn new(name: impl Into<String>, base: impl Into<String>, path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
base: base.into(),
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: String,
pub into: String,
pub filesets: Vec<JjFileset>,
pub use_destination_message: bool,
}
impl SquashPaths {
pub fn new(from: impl Into<String>, into: impl Into<String>) -> Self {
Self {
from: from.into(),
into: into.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
}
}
fn first_bookmark(rendered: &str) -> Option<String> {
let rendered = rendered.trim();
(!rendered.is_empty()).then(|| rendered.split(',').next().unwrap_or(rendered).to_string())
}
fn reject_flag_like(what: &str, value: &str) -> Result<()> {
vcs_cli_support::reject_flag_like(BINARY, what, value)
}
#[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)
}
}
#[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 {
program: BINARY.to_string(),
source: 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_text(&self, dir: &Path) -> Result<String>;
async fn log(&self, dir: &Path, revset: &str, max: usize) -> 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: &str, message: &str) -> Result<()>;
async fn new_change(&self, dir: &Path, message: &str) -> Result<()>;
async fn bookmarks(&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 bookmark_track(&self, dir: &Path, name: &str, remote: &str) -> Result<()>;
async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> 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<String>) -> 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: &str, revision: &str) -> Result<()>;
async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()>;
async fn bookmark_move(
&self,
dir: &Path,
name: &str,
to: &str,
allow_backwards: bool,
) -> Result<()>;
async fn diff_summary(&self, dir: &Path, from: &str, to: &str) -> Result<Vec<ChangedPath>>;
async fn diff_stat(&self, dir: &Path, revset: &str) -> 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: &str) -> Result<usize>;
async fn is_conflicted(&self, dir: &Path, revset: &str) -> Result<bool>;
async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool>;
async fn resolve_list(&self, dir: &Path, revset: &str) -> Result<Vec<String>>;
async fn template_query(
&self,
dir: &Path,
revset: &str,
template: &str,
limit: Option<usize>,
) -> Result<String>;
async fn description(&self, dir: &Path, revset: &str) -> Result<String>;
async fn evolog(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>>;
async fn file_annotate(
&self,
dir: &Path,
path: &str,
revset: Option<String>,
) -> Result<Vec<AnnotationLine>>;
async fn file_show(&self, dir: &Path, revset: &str, path: &str) -> Result<String>;
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()>;
async fn edit(&self, dir: &Path, revset: &str) -> Result<()>;
async fn squash_into(
&self,
dir: &Path,
into: &str,
use_destination_message: bool,
) -> 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<String>) -> Result<()>;
async fn abandon(&self, dir: &Path, revset: &str) -> Result<()>;
async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
async fn git_import(&self, dir: &Path) -> Result<()>;
async fn git_clone(&self, url: &str, dest: &Path, colocate: bool) -> Result<()>;
async fn absorb(&self, dir: &Path, from: Option<String>, filesets: &[JjFileset]) -> Result<()>;
async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
async fn duplicate(&self, dir: &Path, revset: &str) -> 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<()>;
}
processkit::cli_client!(
pub struct Jj => BINARY
);
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.core.command_in(dir, args).arg("--color").arg("never")
}
}
#[async_trait::async_trait]
impl<R: ProcessRunner> JjApi for Jj<R> {
async fn run(&self, args: &[String]) -> Result<String> {
self.core.run(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.run(self.core.command(["--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 {
program: BINARY.to_string(),
message: format!("unrecognisable `jj --version` output: {raw:?}"),
})?;
Ok(JjCapabilities { version })
}
async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>> {
self.core
.parse(
self.cmd_in(dir, ["diff", "-r", "@", "--summary"]),
parse::parse_diff_summary,
)
.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: &str, max: usize) -> Result<Vec<Change>> {
let n = format!("-n{max}");
self.core
.parse(
self.cmd_in(
dir,
[
"log",
"-r",
revset,
n.as_str(),
"--no-graph",
"-T",
parse::CHANGE_TEMPLATE,
],
),
parse::parse_changes,
)
.await
}
async fn current_change(&self, dir: &Path) -> Result<Change> {
let mut changes = self.log(dir, "@", 1).await?;
changes.pop().ok_or_else(|| Error::Parse {
program: BINARY.to_string(),
message: "no working-copy change found".to_string(),
})
}
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: &str, message: &str) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["describe", "-r", revset, "-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 bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
self.core
.parse(
self.cmd_in(dir, ["bookmark", "list"]),
parse::parse_bookmarks,
)
.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.core
.parse(
self.cmd_in(
dir,
[
"log",
"-r",
"heads(::@ & bookmarks())",
"--no-graph",
"-T",
parse::REACHABLE_BOOKMARKS_TEMPLATE,
],
),
parse::parse_reachable_bookmarks,
)
.await
}
async fn bookmark_track(&self, dir: &Path, name: &str, remote: &str) -> Result<()> {
reject_flag_like("bookmark name", name)?;
let target = format!("{name}@{remote}");
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "track", target.as_str()]))
.await
}
async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()> {
reject_flag_like("bookmark name", name)?;
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "set", name, "-r", revision]))
.await
}
async fn git_fetch(&self, dir: &Path) -> Result<()> {
let cmd = self.cmd_in(dir, ["git", "fetch"]).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 cmd = self
.cmd_in(dir, ["git", "fetch", "--remote", remote])
.retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
self.core.run_unit(cmd).await
}
async fn git_push(&self, dir: &Path, bookmark: Option<String>) -> Result<()> {
let mut args = vec!["git", "push"];
if let Some(name) = bookmark.as_deref() {
args.push("-b");
args.push(name);
}
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn root(&self, dir: &Path) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core.run(self.cmd_in(dir, ["root"])).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: &str, revision: &str) -> Result<()> {
reject_flag_like("bookmark name", name)?;
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "create", name, "-r", revision]))
.await
}
async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
reject_flag_like("bookmark name", old)?;
reject_flag_like("bookmark name", new)?;
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "rename", old, new]))
.await
}
async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()> {
reject_flag_like("bookmark name", name)?;
self.core
.run_unit(self.cmd_in(dir, ["bookmark", "delete", name]))
.await
}
async fn bookmark_move(
&self,
dir: &Path,
name: &str,
to: &str,
allow_backwards: bool,
) -> Result<()> {
reject_flag_like("bookmark name", name)?;
let mut args = vec!["bookmark", "move", name, "--to", to];
if allow_backwards {
args.push("--allow-backwards");
}
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn diff_summary(&self, dir: &Path, from: &str, to: &str) -> Result<Vec<ChangedPath>> {
let range = format!("({from})..({to})");
self.core
.parse(
self.cmd_in(dir, ["diff", "-r", range.as_str(), "--summary"]),
parse::parse_diff_summary,
)
.await
}
async fn diff_stat(&self, dir: &Path, revset: &str) -> Result<DiffStat> {
self.core
.parse(
self.cmd_in(dir, ["diff", "-r", revset, "--stat"]),
parse::parse_diff_stat,
)
.await
}
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
let revset = match spec {
DiffSpec::WorkingTree => "@".to_string(),
DiffSpec::Rev(rev) => rev,
};
self.core
.run(self.cmd_in(dir, ["diff", "-r", revset.as_str(), "--git"]))
.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: &str) -> Result<usize> {
self.core
.parse(
self.cmd_in(
dir,
[
"log",
"-r",
revset,
"--no-graph",
"-T",
parse::COUNT_TEMPLATE,
],
),
|s| s.lines().filter(|line| !line.is_empty()).count(),
)
.await
}
async fn is_conflicted(&self, dir: &Path, revset: &str) -> Result<bool> {
let out = self
.core
.run(self.cmd_in(
dir,
[
"log",
"-r",
revset,
"--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, "@").await
}
async fn resolve_list(&self, dir: &Path, revset: &str) -> Result<Vec<String>> {
let res = self
.core
.output(self.cmd_in(dir, ["resolve", "--list", "-r", revset]))
.await?;
match res.code() {
Some(0) => Ok(parse::parse_resolve_list(res.stdout())),
_ if res.stderr().contains("No conflicts") => Ok(Vec::new()),
_ => {
res.ensure_success()?;
Ok(Vec::new()) }
}
}
async fn template_query(
&self,
dir: &Path,
revset: &str,
template: &str,
limit: Option<usize>,
) -> Result<String> {
let mut args: Vec<String> = vec![
"log".into(),
"-r".into(),
revset.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(self.cmd_in(dir, args)).await
}
async fn description(&self, dir: &Path, revset: &str) -> Result<String> {
self.template_query(dir, revset, "description", Some(1))
.await
}
async fn evolog(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>> {
let limit = max.to_string();
self.core
.parse(
self.cmd_in(
dir,
[
"evolog",
"-r",
revset,
"--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<String>,
) -> Result<Vec<AnnotationLine>> {
let mut args = vec!["file", "annotate"];
if let Some(revset) = revset.as_deref() {
args.push("-r");
args.push(revset);
}
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: &str, path: &str) -> Result<String> {
let fileset = JjFileset::path(path);
self.core
.run(self.cmd_in(dir, ["file", "show", "-r", revset, fileset.as_str()]))
.await
}
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["rebase", "-d", onto]))
.await
}
async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()> {
self.core
.run_unit(self.cmd_in(dir, ["rebase", "-b", branch, "-d", dest]))
.await
}
async fn edit(&self, dir: &Path, revset: &str) -> Result<()> {
reject_flag_like("revset", revset)?;
self.core.run_unit(self.cmd_in(dir, ["edit", revset])).await
}
async fn squash_into(
&self,
dir: &Path,
into: &str,
use_destination_message: bool,
) -> Result<()> {
let mut command = self.cmd_in(dir, ["squash", "--into", into]);
if 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<()> {
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,
"--into".into(),
spec.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<String>) -> Result<()> {
for parent in &parents {
reject_flag_like("parent", parent)?;
}
let mut args: Vec<String> = vec!["new".into(), "-m".into(), message.into()];
args.extend(parents);
self.core.run_unit(self.cmd_in(dir, args)).await
}
async fn abandon(&self, dir: &Path, revset: &str) -> Result<()> {
reject_flag_like("revset", revset)?;
self.core
.run_unit(self.cmd_in(dir, ["abandon", revset]))
.await
}
async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
let cmd = self
.cmd_in(dir, ["git", "fetch", "--remote", "origin", "-b", branch])
.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, colocate: bool) -> Result<()> {
reject_flag_like("url", url)?;
let command = self
.core
.command(["git", "clone", url])
.arg(dest)
.arg(if colocate {
"--colocate"
} else {
"--no-colocate"
});
self.core
.run_unit(command.arg("--color").arg("never"))
.await
}
async fn absorb(&self, dir: &Path, from: Option<String>, filesets: &[JjFileset]) -> Result<()> {
let mut args: Vec<String> = vec!["absorb".into()];
if let Some(from) = from.as_deref() {
args.push("--from".into());
args.push(from.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 {
program: BINARY.to_string(),
source: 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: &str) -> Result<()> {
reject_flag_like("revset", revset)?;
self.core
.run_unit(self.cmd_in(dir, ["duplicate", revset]))
.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!["workspace".into(), "root".into()];
if let Some(n) = name.as_deref() {
args.push("--name".into());
args.push(n.to_string());
}
Ok(PathBuf::from(self.core.run(self.cmd_in(dir, args)).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);
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 WORKSPACE_ROOTS_CONCURRENCY: usize = 8;
impl<R: ProcessRunner> Jj<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.run(self.core.command(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, ["workspace", "root", "--name", n.as_str()]));
processkit::output_all(commands, WORKSPACE_ROOTS_CONCURRENCY, self.core.runner())
.await
.into_iter()
.map(|r| {
r.and_then(|pr| pr.ensure_success())
.map(|pr| PathBuf::from(pr.stdout().trim_end()))
})
.collect()
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.output(self.core.command(args)).await
}
pub fn at<'a>(&'a self, dir: &'a Path) -> JjAt<'a, R> {
JjAt { jj: self, dir }
}
pub async fn transaction<'a, T, F, Fut>(&'a self, dir: &'a Path, f: F) -> Result<T>
where
F: FnOnce(JjAt<'a, R>) -> Fut,
Fut: Future<Output = Result<T>> + 'a,
{
let pre = self.op_head(dir).await?;
match f(self.at(dir)).await {
Ok(value) => Ok(value),
Err(err) => {
let _ = self.op_restore(dir, &pre).await;
Err(err)
}
}
}
}
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> {}
macro_rules! jj_at_forwarders {
(
bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
) => {
impl<'a, R: ProcessRunner> JjAt<'a, R> {
$(
#[doc = concat!("Bound form of [`Jj`]'s `", stringify!($bn), "`.")]
pub async fn $bn(&self, $($ba: $bt),*) -> $br {
self.jj.$bn($($ba),*).await
}
)*
$(
#[doc = concat!("Bound form of [`Jj`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
pub async fn $dn(&self, $($da: $dt),*) -> $dr {
self.jj.$dn(self.dir, $($da),*).await
}
)*
}
};
}
jj_at_forwarders! {
bare {
fn run(args: &[String]) -> Result<String>;
fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
fn run_args(args: &[&str]) -> Result<String>;
fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
fn version() -> Result<String>;
fn capabilities() -> Result<JjCapabilities>;
fn git_clone(url: &str, dest: &Path, colocate: bool) -> Result<()>;
}
dir {
fn status() -> Result<Vec<ChangedPath>>;
fn status_text() -> Result<String>;
fn log(revset: &str, max: usize) -> Result<Vec<Change>>;
fn current_change() -> Result<Change>;
fn describe(message: &str) -> Result<()>;
fn describe_rev(revset: &str, message: &str) -> Result<()>;
fn new_change(message: &str) -> Result<()>;
fn bookmarks() -> Result<Vec<Bookmark>>;
fn bookmarks_all() -> Result<Vec<BookmarkRef>>;
fn reachable_bookmarks() -> Result<Vec<Bookmark>>;
fn bookmark_track(name: &str, remote: &str) -> Result<()>;
fn bookmark_set(name: &str, revision: &str) -> Result<()>;
fn git_fetch() -> Result<()>;
fn git_fetch_from(remote: &str) -> Result<()>;
fn git_push(bookmark: Option<String>) -> Result<()>;
fn root() -> Result<PathBuf>;
fn current_bookmark() -> Result<Option<String>>;
fn trunk() -> Result<Option<String>>;
fn bookmark_create(name: &str, revision: &str) -> Result<()>;
fn bookmark_rename(old: &str, new: &str) -> Result<()>;
fn bookmark_delete(name: &str) -> Result<()>;
fn bookmark_move(name: &str, to: &str, allow_backwards: bool) -> Result<()>;
fn diff_summary(from: &str, to: &str) -> Result<Vec<ChangedPath>>;
fn diff_stat(revset: &str) -> Result<DiffStat>;
fn diff_text(spec: DiffSpec) -> Result<String>;
fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
fn commit_count(revset: &str) -> Result<usize>;
fn is_conflicted(revset: &str) -> Result<bool>;
fn has_workingcopy_conflict() -> Result<bool>;
fn resolve_list(revset: &str) -> Result<Vec<String>>;
fn template_query(revset: &str, template: &str, limit: Option<usize>) -> Result<String>;
fn description(revset: &str) -> Result<String>;
fn evolog(revset: &str, max: usize) -> Result<Vec<Change>>;
fn file_annotate(path: &str, revset: Option<String>) -> Result<Vec<AnnotationLine>>;
fn file_show(revset: &str, path: &str) -> Result<String>;
fn absorb(from: Option<String>, filesets: &[JjFileset]) -> Result<()>;
fn split_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
fn duplicate(revset: &str) -> Result<()>;
fn rebase(onto: &str) -> Result<()>;
fn rebase_branch(branch: &str, dest: &str) -> Result<()>;
fn edit(revset: &str) -> Result<()>;
fn squash_into(into: &str, use_destination_message: bool) -> 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<String>) -> Result<()>;
fn abandon(revset: &str) -> Result<()>;
fn git_fetch_branch(branch: &str) -> 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<()>;
}
}
impl<'a, R: ProcessRunner> JjAt<'a, R> {
pub async fn transaction<T, F, Fut>(&self, f: F) -> Result<T>
where
F: FnOnce(JjAt<'a, R>) -> Fut,
Fut: Future<Output = Result<T>> + 'a,
{
self.jj.transaction(self.dir, f).await
}
}
pub mod blocking {
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) -> Option<String> {
let target = normalize(path);
let out = Command::new(super::BINARY)
.current_dir(dir)
.args(["workspace", "list", "-T", "name ++ \"\\n\""])
.output()
.ok()?;
if !out.status.success() {
return None;
}
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(["workspace", "root", "--name", name])
.output();
if let Ok(r) = root
&& r.status.success()
{
let p = PathBuf::from(String::from_utf8_lossy(&r.stdout).trim().to_string());
if normalize(&p) == target || p == target || p == path {
return Some(name.to_string());
}
}
}
None
}
fn normalize(p: &Path) -> PathBuf {
let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
#[cfg(windows)]
{
let s = canonical.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\")
&& !rest.starts_with("UNC\\")
{
return PathBuf::from(rest.to_string());
}
}
canonical
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_jj() {
assert_eq!(BINARY, "jj");
}
#[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, "main", "@", true).await.unwrap();
jj.at(dir).bookmark_move("main", "@", true).await.unwrap();
jj.describe_rev(dir, "feat", "msg").await.unwrap();
jj.at(dir).describe_rev("feat", "msg").await.unwrap();
jj.description(dir, "@-").await.unwrap();
jj.at(dir).description("@-").await.unwrap();
jj.duplicate(dir, "@-").await.unwrap();
jj.at(dir).duplicate("@-").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.as_os_str()));
}
#[tokio::test]
async fn workspace_list_parses_template_rows() {
let jj = Jj::with_runner(ScriptedRunner::new().on(
["workspace", "list"],
Reply::ok("default\te2aa3420\tmain\nws1\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(
["workspace", "root", "--name", "default"],
Reply::ok("/repo\n"),
)
.on(
["workspace", "root", "--name", "ws1"],
Reply::ok("/repo/ws1\n"),
)
.on(
["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()[..2] == ["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", "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", "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(),
"file:\"src/a(b).rs\""
);
assert_eq!(JjFileset::path("src\\a.rs").as_str(), "file:\"src/a.rs\"");
assert_eq!(JjFileset::path("a\"b").as_str(), "file:\"a\\\"b\"");
}
#[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",
"file:\"x|y.rs\"",
"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("@", "feat").filesets([JjFileset::path("a.rs")]),
)
.await
.expect("squash_paths");
assert_eq!(
rec.only_call().args_str(),
[
"squash",
"--from",
"@",
"--into",
"feat",
"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("@", "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",
"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("."), "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("."), "feat", "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("."), "feat", "origin")
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["bookmark", "track", "feat@origin", "--color", "never"]
);
}
#[tokio::test]
async fn bookmarks_all_parses_local_and_remote() {
let jj = Jj::with_runner(ScriptedRunner::new().on(
["bookmark", "list"],
Reply::ok("main\t\t0\tabc123\nmain\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(
["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, "b.rs");
}
#[tokio::test]
async fn status_text_is_raw_jj_status() {
let jj = Jj::with_runner(
ScriptedRunner::new().on(["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(["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"), "main", "@", true)
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
[
"bookmark",
"move",
"main",
"--to",
"@",
"--allow-backwards",
"--color",
"never"
]
);
}
#[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!["p1".into(), "p2".into()])
.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(["log"], Reply::ok("1\n")));
assert!(yes.is_conflicted(Path::new("."), "@").await.unwrap());
let no = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
assert!(!no.is_conflicted(Path::new("."), "@").await.unwrap());
}
#[tokio::test]
async fn commit_count_counts_template_lines() {
let jj = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("a\nb\nc\n")));
assert_eq!(jj.commit_count(Path::new("."), "::@").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(
["resolve"],
Reply::fail(2, "Error: No conflicts found at this revision"),
));
assert!(
none.resolve_list(Path::new("."), "@")
.await
.unwrap()
.is_empty()
);
let bad = Jj::with_runner(ScriptedRunner::new().on(
["resolve"],
Reply::fail(1, "Error: Revision `bogus` doesn't exist"),
));
assert!(bad.resolve_list(Path::new("."), "bogus").await.is_err());
let some = Jj::with_runner(
ScriptedRunner::new().on(["resolve"], Reply::ok("a.rs 2-sided conflict\n")),
);
assert_eq!(
some.resolve_list(Path::new("."), "@").await.unwrap(),
["a.rs"]
);
}
#[tokio::test]
async fn current_bookmark_takes_first_or_none() {
let some = Jj::with_runner(ScriptedRunner::new().on(["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(["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(["log"], Reply::ok("kztuxlro\t38e00654\tfalse\thello 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(["git", "push", "-b", "feature"], Reply::ok("")),
);
jj.git_push(Path::new("."), Some("feature".to_string()))
.await
.expect("should build `git push -b feature`");
}
#[tokio::test]
async fn git_push_without_bookmark_is_bare() {
let jj = Jj::with_runner(ScriptedRunner::new().on(["git", "push"], Reply::ok("")));
jj.git_push(Path::new("."), None).await.expect("bare push");
}
#[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);
}
#[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", "upstream", "--color", "never"]
);
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(["op", "log"], Reply::ok("abc123\n"))
.on(["op", "restore"], Reply::ok(""))
.on(["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, Error::Exit { .. }));
let calls = rec.calls();
assert_eq!(calls.len(), 3, "op head, mutation, 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()[..3], ["op", "restore", "abc123"]);
}
#[tokio::test]
async fn transaction_keeps_changes_on_success() {
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["op", "log"], Reply::ok("abc123\n"))
.on(["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);
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(["op", "log"], Reply::ok("op9\n"))
.on(["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.as_os_str()));
}
#[tokio::test]
async fn flag_like_positionals_are_rejected_before_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
let dir = Path::new("/r");
assert!(jj.bookmark_create(dir, "-evil", "@").await.is_err());
assert!(jj.bookmark_rename(dir, "ok", "-bad").await.is_err());
assert!(jj.bookmark_delete(dir, "--all").await.is_err());
assert!(jj.bookmark_move(dir, "-evil", "@", false).await.is_err());
assert!(jj.edit(dir, "-evil").await.is_err());
assert!(jj.duplicate(dir, "-r").await.is_err());
assert!(jj.abandon(dir, "-evil").await.is_err());
assert!(
jj.bookmark_track(dir, "--config=x", "origin")
.await
.is_err(),
"name leads the {{name}}@{{remote}} token"
);
assert!(jj.bookmark_set(dir, "-evil", "@").await.is_err());
assert!(jj.op_restore(dir, "--help").await.is_err());
assert!(jj.workspace_forget(dir, "-evil").await.is_err());
assert!(
jj.new_merge(dir, "m", vec!["@".into(), "--ignore-working-copy".into()])
.await
.is_err(),
"a flag-shaped parent is refused"
);
assert!(jj.git_clone("-evil", dir, false).await.is_err());
assert!(jj.edit(dir, "").await.is_err(), "empty refused too");
assert!(
rec.calls().is_empty(),
"nothing may spawn: {:?}",
rec.calls()
);
jj.edit(dir, "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(["--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(["--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(["--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(["--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"), true)
.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"), false).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 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("@-".into()),
&[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"), "@-").await.unwrap();
let calls = rec.calls();
assert_eq!(calls[0].args_str(), ["absorb", "--color", "never"]);
assert_eq!(
calls[1].args_str(),
[
"absorb",
"--from",
"@-",
"file:\"src/a.rs\"",
"--color",
"never"
]
);
assert_eq!(
calls[2].args_str(),
[
"split",
"-m",
"split out b",
"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 op_log_parses_template_rows() {
let rec = RecordingRunner::new(ScriptedRunner::new().on(
["op", "log"],
Reply::ok("abc\tu@h\t2026-06-05T10:00:00+0200\tnew 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(["evolog"], Reply::ok("kz\t38\tfalse\twip\n")),
);
let jj = Jj::with_runner(&rec);
let rows = jj.evolog(Path::new("."), "@", 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(
["file", "annotate"],
Reply::ok("kz\tline one\nkz\tline two"),
)
.on(["file", "show"], Reply::ok("content\n")),
);
let jj = Jj::with_runner(&rec);
let lines = jj
.file_annotate(Path::new("."), "src/a.rs", Some("@-".into()))
.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("."), "@-", "src/a.rs")
.await
.unwrap(),
"content"
);
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",
"@-",
"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("."), "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 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(["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, "m");
assert_eq!(files[0].change, ChangeKind::Modified);
}
#[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 {}