use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
pub use processkit::{Error, ProcessResult, Result};
mod parse;
pub use parse::{
Bookmark, 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 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 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 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 commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
async fn squash_paths(
&self,
dir: &Path,
from: &str,
into: &str,
filesets: &[JjFileset],
) -> 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
);
#[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.core.command_in(dir, ["diff", "-r", "@", "--summary"]),
parse::parse_diff_summary,
)
.await
}
async fn status_text(&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 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.core
.command_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.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 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.core.command_in(dir, args)).await
}
async fn squash_paths(
&self,
dir: &Path,
from: &str,
into: &str,
filesets: &[JjFileset],
) -> Result<()> {
let mut args: Vec<String> = vec![
"squash".into(),
"--from".into(),
from.into(),
"--into".into(),
into.into(),
];
args.extend(filesets.iter().map(|f| f.as_str().to_string()));
self.core.unit(self.core.command_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.core.command_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.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 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);
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
}
}
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
}
}
#[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 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"
]
);
}
#[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\""]
);
}
#[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")])
.await
.expect("squash_paths");
assert_eq!(
rec.only_call().args_str(),
["squash", "--from", "@", "--into", "feat", "file:\"a.rs\""]
);
}
#[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"
]
);
}
#[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"]
);
}
#[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");
}
#[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"]);
}
#[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());
}
}