use std::path::{Path, PathBuf};
use std::time::Duration;
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
mod parse;
pub use parse::{
Bookmark, BookmarkRef, Change, ChangeKind, ChangedPath, DiffLine, DiffStat, FileDiff, Hunk,
Workspace,
};
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
}
}
fn first_bookmark(rendered: &str) -> Option<String> {
let rendered = rendered.trim();
(!rendered.is_empty()).then(|| rendered.split(',').next().unwrap_or(rendered).to_string())
}
#[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 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_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 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,
from: &str,
into: &str,
filesets: &[JjFileset],
use_destination_message: bool,
) -> 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 op_head(&self, dir: &Path) -> Result<String>;
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.text(self.core.command(args)).await
}
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
self.core.capture(self.core.command(args)).await
}
async fn version(&self) -> Result<String> {
self.core.text(self.core.command(["--version"])).await
}
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.text(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
.unit(self.cmd_in(dir, ["describe", "-m", message]))
.await
}
async fn describe_rev(&self, dir: &Path, revset: &str, message: &str) -> Result<()> {
self.core
.unit(self.cmd_in(dir, ["describe", "-r", revset, "-m", message]))
.await
}
async fn new_change(&self, dir: &Path, message: &str) -> Result<()> {
self.core
.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<()> {
let target = format!("{name}@{remote}");
self.core
.unit(self.cmd_in(dir, ["bookmark", "track", target.as_str()]))
.await
}
async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()> {
self.core
.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.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.unit(self.cmd_in(dir, args)).await
}
async fn root(&self, dir: &Path) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core.text(self.cmd_in(dir, ["root"])).await?,
))
}
async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>> {
let out = self
.core
.text(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
.text(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<()> {
self.core
.unit(self.cmd_in(dir, ["bookmark", "create", name, "-r", revision]))
.await
}
async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
self.core
.unit(self.cmd_in(dir, ["bookmark", "rename", old, new]))
.await
}
async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()> {
self.core
.unit(self.cmd_in(dir, ["bookmark", "delete", name]))
.await
}
async fn bookmark_move(
&self,
dir: &Path,
name: &str,
to: &str,
allow_backwards: bool,
) -> Result<()> {
let mut args = vec!["bookmark", "move", name, "--to", to];
if allow_backwards {
args.push("--allow-backwards");
}
self.core.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
.text(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::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
.text(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
.capture(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.text(self.cmd_in(dir, args)).await
}
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
self.core
.unit(self.cmd_in(dir, ["rebase", "-d", onto]))
.await
}
async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()> {
self.core
.unit(self.cmd_in(dir, ["rebase", "-b", branch, "-d", dest]))
.await
}
async fn edit(&self, dir: &Path, revset: &str) -> Result<()> {
self.core.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.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.unit(self.cmd_in(dir, args)).await
}
async fn squash_paths(
&self,
dir: &Path,
from: &str,
into: &str,
filesets: &[JjFileset],
use_destination_message: bool,
) -> Result<()> {
let mut args: Vec<String> = vec![
"squash".into(),
"--from".into(),
from.into(),
"--into".into(),
into.into(),
];
if use_destination_message {
args.push("--use-destination-message".into());
}
args.extend(filesets.iter().map(|f| f.as_str().to_string()));
self.core.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.unit(self.cmd_in(dir, args)).await
}
async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<String>) -> Result<()> {
let mut args: Vec<String> = vec!["new".into(), "-m".into(), message.into()];
args.extend(parents);
self.core.unit(self.cmd_in(dir, args)).await
}
async fn abandon(&self, dir: &Path, revset: &str) -> Result<()> {
self.core.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.unit(cmd).await
}
async fn git_import(&self, dir: &Path) -> Result<()> {
self.core.unit(self.cmd_in(dir, ["git", "import"])).await
}
async fn op_head(&self, dir: &Path) -> Result<String> {
self.core
.text(self.cmd_in(
dir,
[
"op",
"log",
"--no-graph",
"--limit",
"1",
"-T",
"id.short()",
],
))
.await
}
async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()> {
self.core
.unit(self.cmd_in(dir, ["op", "restore", op_id]))
.await
}
async fn op_undo(&self, dir: &Path) -> Result<()> {
self.core.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.text(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.unit(command).await
}
async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()> {
self.core
.unit(self.cmd_in(dir, ["workspace", "forget", name]))
.await
}
}
const FETCH_ATTEMPTS: u32 = 3;
const FETCH_BACKOFF: Duration = Duration::from_millis(500);
const TRANSIENT_FETCH_MARKERS: &[&str] = &[
"could not resolve host",
"couldn't resolve host",
"temporary failure in name resolution",
"connection timed out",
"connection refused",
"operation timed out",
"timed out",
"network is unreachable",
"failed to connect",
"could not read from remote repository",
"the remote end hung up",
"early eof",
"rpc failed",
];
pub fn is_transient_fetch_error(err: &Error) -> bool {
if matches!(err, Error::Timeout { .. }) {
return true;
}
let Error::Exit { stdout, stderr, .. } = err else {
return false;
};
let out = stdout.to_ascii_lowercase();
let errt = stderr.to_ascii_lowercase();
TRANSIENT_FETCH_MARKERS
.iter()
.any(|m| out.contains(m) || errt.contains(m))
}
impl<R: ProcessRunner> Jj<R> {
pub async fn run_args(&self, args: &[&str]) -> Result<String> {
self.core.text(self.core.command(args)).await
}
pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
self.core.capture(self.core.command(args)).await
}
pub fn at<'a>(&'a self, dir: &'a Path) -> JjAt<'a, R> {
JjAt { jj: self, dir }
}
}
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>;
}
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_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 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(from: &str, into: &str, filesets: &[JjFileset], use_destination_message: bool) -> 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_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<()>;
}
}
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();
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[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_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("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("."),
"@",
"feat",
&[JjFileset::path("a.rs")],
false,
)
.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("."),
"@",
"feat",
&[JjFileset::path("a.rs")],
true,
)
.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");
}
#[test]
fn classifies_transient_fetch() {
let dns = Error::Exit {
program: "jj".into(),
code: 1,
stdout: String::new(),
stderr: "Error: Could not resolve host: example.com".into(),
};
assert!(is_transient_fetch_error(&dns));
let other = Error::Exit {
program: "jj".into(),
code: 1,
stdout: String::new(),
stderr: "Error: No such revision".into(),
};
assert!(!is_transient_fetch_error(&other));
let timeout = Error::Timeout {
program: "jj".into(),
timeout: std::time::Duration::from_secs(10),
};
assert!(is_transient_fetch_error(&timeout));
}
#[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 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());
}
}