use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
mod parse;
pub use parse::{Bookmark, Change, ChangedPath, DiffStat, Workspace};
pub const BINARY: &str = "jj";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WorkspaceAdd {
pub name: String,
pub base: String,
pub path: PathBuf,
}
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(),
}
}
}
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<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 new_change(&self, dir: &Path, message: &str) -> Result<()>;
async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
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 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 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 edit(&self, dir: &Path, revset: &str) -> Result<()>;
async fn squash_into(&self, dir: &Path, into: &str) -> 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
);
#[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<String> {
self.core.text(self.core.command_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.core.command_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.core.command_in(dir, ["describe", "-m", message]))
.await
}
async fn new_change(&self, dir: &Path, message: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["new", "-m", message]))
.await
}
async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
self.core
.parse(
self.core.command_in(dir, ["bookmark", "list"]),
parse::parse_bookmarks,
)
.await
}
async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()> {
self.core
.unit(
self.core
.command_in(dir, ["bookmark", "set", name, "-r", revision]),
)
.await
}
async fn git_fetch(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["git", "fetch"]))
.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.core.command_in(dir, args)).await
}
async fn root(&self, dir: &Path) -> Result<PathBuf> {
Ok(PathBuf::from(
self.core.text(self.core.command_in(dir, ["root"])).await?,
))
}
async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>> {
let out = self
.core
.text(self.core.command_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.core.command_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.core
.command_in(dir, ["bookmark", "create", name, "-r", revision]),
)
.await
}
async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["bookmark", "rename", old, new]))
.await
}
async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()> {
self.core
.unit(self.core.command_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.core.command_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.core
.command_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.core.command_in(dir, ["diff", "-r", revset, "--stat"]),
parse::parse_diff_stat,
)
.await
}
async fn commit_count(&self, dir: &Path, revset: &str) -> Result<usize> {
self.core
.parse(
self.core.command_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.core.command_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 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.core.command_in(dir, args)).await
}
async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["rebase", "-d", onto]))
.await
}
async fn edit(&self, dir: &Path, revset: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["edit", revset]))
.await
}
async fn squash_into(&self, dir: &Path, into: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["squash", "--into", into]))
.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.core.command_in(dir, args)).await
}
async fn abandon(&self, dir: &Path, revset: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["abandon", revset]))
.await
}
async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
self.core
.unit(
self.core
.command_in(dir, ["git", "fetch", "--remote", "origin", "-b", branch]),
)
.await
}
async fn git_import(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["git", "import"]))
.await
}
async fn op_head(&self, dir: &Path) -> Result<String> {
self.core
.text(self.core.command_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.core.command_in(dir, ["op", "restore", op_id]))
.await
}
async fn op_undo(&self, dir: &Path) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["op", "undo"]))
.await
}
async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>> {
self.core
.parse(
self.core
.command_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.core.command_in(dir, args)).await?,
))
}
async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()> {
let command = self
.core
.command_in(dir, ["workspace", "add", "--name"])
.arg(&spec.name)
.arg("-r")
.arg(&spec.base)
.arg(&spec.path);
self.core.unit(command).await
}
async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()> {
self.core
.unit(self.core.command_in(dir, ["workspace", "forget", name]))
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
#[test]
fn binary_name_is_jj() {
assert_eq!(BINARY, "jj");
}
#[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"]
);
}
#[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"]
);
}
#[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"]);
}
#[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 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");
}
#[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());
}
}