use crate::error::{JjjError, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct JjClient {
jj_path: PathBuf,
repo_root: PathBuf,
}
impl JjClient {
pub fn new() -> Result<Self> {
let jj_path = find_executable("jj").ok_or(JjjError::JjNotFound)?;
let repo_root = Self::find_repo_root()?;
Ok(Self { jj_path, repo_root })
}
pub fn with_root(root: PathBuf) -> Result<Self> {
let jj_path = find_executable("jj").ok_or(JjjError::JjNotFound)?;
Ok(Self {
jj_path,
repo_root: root,
})
}
fn find_repo_root() -> Result<PathBuf> {
let current_dir = std::env::current_dir()?;
let mut dir = current_dir.as_path();
loop {
let jj_dir = dir.join(".jj");
if jj_dir.exists() && jj_dir.is_dir() {
return Ok(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => return Err(JjjError::NotInRepository),
}
}
}
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
pub fn execute(&self, args: &[&str]) -> Result<String> {
if std::env::var("JJJ_DEBUG").is_ok() {
eprintln!("DEBUG: jj {}", args.join(" "));
}
let output = Command::new(&self.jj_path)
.args(args)
.current_dir(&self.repo_root)
.output()
.map_err(|e| crate::error::JjjError::JjIo {
args: args.join(" "),
source: e,
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(crate::error::JjjError::JjCommandFailed {
args: args.join(" "),
stderr,
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn current_change_id(&self) -> Result<String> {
let output = self.execute(&["log", "--no-graph", "-r", "@", "-T", "change_id"])?;
Ok(output.trim().to_string())
}
pub fn bookmark_exists(&self, bookmark: &str) -> Result<bool> {
let output = self.execute(&["bookmark", "list"])?;
Ok(output.lines().any(|line| {
let name = line.split_whitespace().next().unwrap_or("");
name == bookmark || name.trim_end_matches(':') == bookmark
}))
}
pub fn create_bookmark(&self, name: &str, revision: &str) -> Result<()> {
self.execute(&["bookmark", "create", name, "-r", revision])?;
Ok(())
}
pub fn checkout(&self, revision: &str) -> Result<()> {
self.execute(&["new", revision])?;
Ok(())
}
pub fn new_empty_change(&self, message: &str) -> Result<String> {
self.execute(&["new"])?;
self.describe(message)?;
self.current_change_id()
}
pub fn new_orphan_change(&self, message: &str) -> Result<String> {
self.execute(&["new", "-r", "root()"])?;
self.describe(message)?;
self.current_change_id()
}
pub fn describe(&self, message: &str) -> Result<()> {
self.execute(&["describe", "-m", message])?;
Ok(())
}
pub fn change_description(&self, change_id: &str) -> Result<String> {
let output = self.execute(&["log", "--no-graph", "-r", change_id, "-T", "description"])?;
Ok(output.trim().to_string())
}
pub fn log_descriptions(&self, revset: &str) -> Result<Vec<String>> {
let output = self.execute(&[
"log",
"--no-graph",
"-r",
revset,
"-T",
r#"description ++ "\x00""#,
])?;
Ok(output
.split('\x00')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
pub fn change_author(&self, change_id: &str) -> Result<String> {
let output = self.execute(&["log", "--no-graph", "-r", change_id, "-T", "author"])?;
Ok(output.trim().to_string())
}
pub fn show_diff(&self, change_id: &str) -> Result<String> {
self.execute(&["diff", "-r", change_id])
}
pub fn changed_files(&self, change_id: &str) -> Result<Vec<PathBuf>> {
let output = self.execute(&["diff", "-r", change_id, "--summary"])?;
let files: Vec<PathBuf> = output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
Some(PathBuf::from(parts[1]))
} else {
None
}
})
.collect();
Ok(files)
}
pub fn file_at_revision(&self, revision: &str, path: &str) -> Result<String> {
self.execute(&["file", "show", "-r", revision, path])
}
pub fn squash(&self, message: Option<&str>) -> Result<()> {
match message {
Some(msg) => self.execute(&["squash", "-m", msg])?,
None => self.execute(&["squash"])?,
};
Ok(())
}
pub fn edit(&self, change_id: &str) -> Result<()> {
self.execute(&["edit", change_id])?;
Ok(())
}
pub fn change_exists(&self, change_id: &str) -> Result<bool> {
match self.execute(&["log", "--no-graph", "-r", change_id, "-T", "change_id"]) {
Ok(_) => Ok(true),
Err(crate::error::JjjError::JjCommandFailed { .. }) => Ok(false),
Err(e) => Err(e),
}
}
pub fn user_name(&self) -> Result<String> {
let output = self.execute(&["config", "get", "user.name"])?;
Ok(output.trim().trim_matches('"').to_string())
}
pub fn user_email(&self) -> Result<String> {
let output = self.execute(&["config", "get", "user.email"])?;
Ok(output.trim().trim_matches('"').to_string())
}
pub fn user_identity(&self) -> Result<String> {
let name = self.user_name()?;
let email = self.user_email()?;
Ok(format!("{} <{}>", name, email))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_jj_detection() {
match find_executable("jj") {
Some(_) => println!("jj found in PATH"),
None => println!("jj not found - some tests will be skipped"),
}
}
}
pub fn find_executable(name: &str) -> Option<PathBuf> {
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.map(|dir| dir.join(name))
.find(|path| path.is_file())
}