use std::{
path::PathBuf,
process::{Command, ExitStatus},
};
use tracing::{debug, trace};
use crate::error::{Error, Result};
pub struct Jujutsu {
repo_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct CommandOutput {
pub status: ExitStatus,
pub stdout: String,
pub stderr: String,
}
impl Jujutsu {
pub fn new(repo_path: PathBuf) -> Result<Self> {
which_jj()?; Ok(Self { repo_path })
}
pub fn run_captured(&self, args: &[&str]) -> Result<CommandOutput> {
trace!("Running jj command: jj {}", args.join(" "));
run_jj_command(&self.repo_path, args)
}
pub fn get_bookmarks(&self) -> Result<Vec<Bookmark>> {
let output = self.run_captured(&[
"log",
"-r",
"mine() & bookmarks()",
"--no-graph",
"--template",
r#"bookmarks.map(|b| b ++ "\t" ++ commit_id ++ "\t" ++ change_id).join("\n") ++ "\n""#,
])?;
let mut bookmarks = Vec::new();
for line in output.stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let full_name = parts[0];
let full_name = full_name.strip_suffix('*').unwrap_or(full_name);
let (name, remote) = if let Some(at_pos) = full_name.rfind('@') {
let name = full_name[..at_pos].to_string();
let remote = full_name[at_pos + 1..].to_string();
(name, Some(remote))
} else {
(full_name.to_string(), None)
};
let is_local = remote.is_none();
let has_remote = false;
bookmarks.push(Bookmark {
name,
commit_id: parts[1].to_string(),
change_id: parts[2].to_string(),
remote,
is_local,
has_remote,
});
}
}
Ok(bookmarks)
}
pub fn get_bookmarks_with_revset(&self, revset: &str) -> Result<Vec<Bookmark>> {
let output = self.run_captured(&[
"log",
"-r",
revset,
"--no-graph",
"--template",
r#"bookmarks.map(|b| b ++ "\t" ++ commit_id ++ "\t" ++ change_id).join("\n") ++ "\n""#,
])?;
let mut bookmarks = Vec::new();
for line in output.stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let full_name = parts[0];
let full_name = full_name.strip_suffix('*').unwrap_or(full_name);
let (name, remote) = if let Some(at_pos) = full_name.rfind('@') {
let name = full_name[..at_pos].to_string();
let remote = full_name[at_pos + 1..].to_string();
(name, Some(remote))
} else {
(full_name.to_string(), None)
};
let is_local = remote.is_none();
let has_remote = false;
bookmarks.push(Bookmark {
name,
commit_id: parts[1].to_string(),
change_id: parts[2].to_string(),
remote,
is_local,
has_remote,
});
}
}
Ok(bookmarks)
}
pub fn count_commits_in_revset(&self, revset: &str) -> Result<usize> {
let output = self.run_captured(&[
"log",
"-r",
revset,
"--no-graph",
"--template",
"commit_id ++ \"\\n\"",
])?;
Ok(output
.stdout
.lines()
.filter(|line| !line.trim().is_empty())
.count())
}
pub fn get_changes(&self, from: &str, to: &str) -> Result<Vec<Change>> {
let revset = format!("{}::{}", from, to);
let output = self.run_captured(&[
"log",
"-r",
&revset,
"--no-graph",
"--template",
r#"commit_id ++ "\t" ++ change_id ++ "\t" ++ description.first_line() ++ "\t" ++ parents.map(|p| p.commit_id()).join(",") ++ "\n""#,
])?;
let mut changes = Vec::new();
for line in output.stdout.lines() {
if line.trim().is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 4 {
changes.push(Change {
commit_id: parts[0].to_string(),
change_id: parts[1].to_string(),
description_first_line: parts[2].to_string(),
parent_ids: parts[3]
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect(),
});
}
}
Ok(changes)
}
pub fn resolve_revision(&self, revset: &str) -> Result<String> {
trace!("Resolving revision: {}", revset);
let output = self.run_captured(&[
"log",
"-r",
revset,
"--limit",
"1",
"--no-graph",
"--template",
"commit_id",
])?;
Ok(output.stdout.trim().to_string())
}
pub fn get_change_id(&self, commit_id: &str) -> Result<String> {
let output = self.run_captured(&[
"log",
"-r",
commit_id,
"--limit",
"1",
"--no-graph",
"--template",
"change_id",
])?;
Ok(output.stdout.trim().to_string())
}
pub fn get_default_branch(&self) -> Result<String> {
for branch in &["main", "master", "trunk"] {
let revset = format!("{}@origin", branch);
if self.resolve_revision(&revset).is_ok() {
return Ok(branch.to_string());
}
}
Err(Error::Config {
message: "Could not find default branch (tried main, master, trunk)".to_string(),
})
}
pub fn track_bookmark(&self, bookmark: &str, remote: &str) -> Result<()> {
let remote_bookmark = format!("{}@{}", bookmark, remote);
self.run_captured(&["bookmark", "track", &remote_bookmark])?;
Ok(())
}
pub fn push_bookmark(&self, bookmark: &str, remote: &str) -> Result<bool> {
let _ = self.track_bookmark(bookmark, remote);
let output =
self.run_captured(&["git", "push", "--remote", remote, "--bookmark", bookmark])?;
Ok(!output.stderr.contains("Nothing changed."))
}
pub fn list_remotes(&self) -> Result<Vec<String>> {
let output = self.run_captured(&["git", "remote", "list"])?;
Ok(output
.stdout
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect())
}
pub fn remote_bookmark_exists(&self, bookmark: &str, remote: &str) -> Result<bool> {
trace!(
"Checking if bookmark '{}' exists on remote '{}'",
bookmark, remote
);
let revset = format!("{}@{}", bookmark, remote);
match self.resolve_revision(&revset) {
Ok(_) => {
trace!("Bookmark '{}@{}' exists", bookmark, remote);
Ok(true)
}
Err(Error::JjCommand { .. }) => {
trace!("Bookmark '{}@{}' does not exist", bookmark, remote);
Ok(false)
}
Err(e) => Err(e),
}
}
pub fn get_tracked_bookmarks(&self, remote: &str) -> Result<Vec<String>> {
debug!("Getting tracked bookmarks for remote: {}", remote);
let default_branch = match self.get_default_branch() {
Ok(branch) => {
debug!("Default branch is '{}'", branch);
Some(branch)
}
Err(_) => {
debug!("No default branch found, will not filter any branches");
None
}
};
debug!("Running jj log to get mine() & bookmarks()");
let output = self.run_captured(&[
"log",
"-r",
"mine() & bookmarks()",
"--no-graph",
"--template",
r#"bookmarks.map(|b| b ++ "\n").join("")"#,
])?;
debug!("Got bookmarks output, processing lines");
let mut tracked = Vec::new();
for line in output.stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let bookmark_name = line.strip_suffix('*').unwrap_or(line);
if bookmark_name.contains('@') {
continue;
}
if let Some(ref default_branch) = default_branch
&& bookmark_name == default_branch
{
debug!(
"Skipping default branch '{}' from tracked bookmarks",
bookmark_name
);
continue;
}
debug!("Checking if bookmark '{}' exists on remote", bookmark_name);
if self.remote_bookmark_exists(bookmark_name, remote)? {
debug!(
"Bookmark '{}' exists on remote, adding to tracked list",
bookmark_name
);
tracked.push(bookmark_name.to_string());
} else {
debug!(
"Bookmark '{}' does not exist on remote, skipping",
bookmark_name
);
}
}
debug!("Found {} tracked bookmarks", tracked.len());
Ok(tracked)
}
}
pub fn which_jj() -> Result<PathBuf> {
if let Ok(jj_path) = std::env::var("JJ") {
return Ok(PathBuf::from(jj_path));
}
which::which("jj").map_err(|e| Error::Config {
message: format!("jj binary not found in PATH: {}", e),
})
}
pub fn run_jj_command(repo_path: &PathBuf, args: &[&str]) -> Result<CommandOutput> {
let jj_bin = which_jj()?;
let output = Command::new(&jj_bin)
.current_dir(repo_path)
.args(args)
.output()?;
if !output.status.success() {
return Err(Error::JjCommand {
message: format!(
"jj {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
),
output: Some(output),
});
}
trace!(
"jj command output: {}",
String::from_utf8_lossy(&output.stdout)
);
trace!(
"jj command stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok(CommandOutput {
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct Bookmark {
pub name: String,
pub commit_id: String,
pub change_id: String,
pub remote: Option<String>,
pub is_local: bool,
pub has_remote: bool,
}
#[derive(Debug, Clone)]
pub struct Change {
pub commit_id: String,
pub change_id: String,
pub description_first_line: String,
pub parent_ids: Vec<String>,
}
#[cfg(test)]
mod tests {
use std::process::Command as StdCommand;
use tempfile::TempDir;
use super::*;
fn create_test_repo() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = temp_dir.path().to_path_buf();
run_jj_command(&repo_path, &["git", "init"]).expect("Failed to init jj repo");
run_jj_command(
&repo_path,
&["config", "set", "--repo", "user.name", "Test User"],
)
.expect("Failed to set user name");
run_jj_command(
&repo_path,
&["config", "set", "--repo", "user.email", "test@example.com"],
)
.expect("Failed to set user email");
run_jj_command(&repo_path, &["metaedit", "--update-author"])
.expect("Failed to update author");
std::fs::write(repo_path.join("README.md"), "# Test repo\n")
.expect("Failed to write README");
let output = StdCommand::new(which_jj().expect("jj not found"))
.current_dir(&repo_path)
.args(["describe", "-m", "Initial commit"])
.output()
.expect("Failed to create initial commit");
assert!(
output.status.success(),
"Failed to create initial commit: {}",
String::from_utf8_lossy(&output.stderr)
);
(temp_dir, repo_path)
}
#[test]
fn test_which_jj() {
let jj_path = which_jj().expect("jj binary must be available in PATH");
assert!(jj_path.exists());
}
#[test]
fn test_jujutsu_new() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path).expect("Failed to create Jujutsu instance");
assert!(jj.repo_path.exists());
}
#[test]
fn test_resolve_revision() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path).expect("Failed to create Jujutsu instance");
let commit_id = jj
.resolve_revision("@")
.expect("Failed to resolve @ revision");
assert!(!commit_id.is_empty());
assert!(commit_id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_get_change_id() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path).expect("Failed to create Jujutsu instance");
let commit_id = jj.resolve_revision("@").expect("Failed to resolve @");
let change_id = jj
.get_change_id(&commit_id)
.expect("Failed to get change ID");
assert!(!change_id.is_empty());
assert_eq!(change_id.len(), 32, "Change ID should be 32 characters");
assert!(
change_id.chars().all(|c| c.is_ascii_lowercase()),
"Change ID should be lowercase letters"
);
}
#[test]
fn test_get_bookmarks() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path.clone()).expect("Failed to create Jujutsu instance");
let output = StdCommand::new(which_jj().expect("jj not found"))
.current_dir(&repo_path)
.args(["bookmark", "create", "test-feature"])
.output()
.expect("Failed to create bookmark");
assert!(
output.status.success(),
"Failed to create bookmark: {}",
String::from_utf8_lossy(&output.stderr)
);
let bookmarks = jj.get_bookmarks().expect("Failed to get bookmarks");
assert!(bookmarks.iter().any(|b| b.name == "test-feature"));
}
#[test]
fn test_get_default_branch_no_remote() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path).expect("Failed to create Jujutsu instance");
let result = jj.get_default_branch();
assert!(result.is_err());
assert!(
matches!(result, Err(Error::Config { .. })),
"Expected Config error, got {:?}",
result
);
if let Err(Error::Config { message }) = result {
assert!(message.contains("Could not find default branch"));
}
}
#[test]
fn test_bookmark_parsing() {
let bookmark = Bookmark {
name: "feature".to_string(),
commit_id: "abc123".to_string(),
change_id: "xyz789".to_string(),
remote: None,
is_local: true,
has_remote: false,
};
assert_eq!(bookmark.name, "feature");
assert!(bookmark.is_local);
assert!(!bookmark.has_remote);
}
#[test]
fn test_change_structure() {
let change = Change {
commit_id: "abc123".to_string(),
change_id: "xyz789".to_string(),
description_first_line: "Add feature".to_string(),
parent_ids: vec!["parent1".to_string()],
};
assert_eq!(change.description_first_line, "Add feature");
assert_eq!(change.parent_ids.len(), 1);
}
#[test]
fn test_get_changes() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path.clone()).expect("Failed to create Jujutsu instance");
let _initial_commit = jj.resolve_revision("@").expect("Failed to resolve @");
std::fs::write(repo_path.join("test.txt"), "test content\n")
.expect("Failed to write test file");
run_jj_command(&repo_path, &["describe", "-m", "Second commit"])
.expect("Failed to describe commit");
let changes = jj
.get_changes("root()", "@")
.expect("Failed to get changes");
assert!(
changes.len() >= 2,
"Expected at least 2 changes, got {}",
changes.len()
);
for change in &changes {
assert_eq!(
change.commit_id.len(),
40,
"Commit ID should be 40 characters"
);
assert!(
change.commit_id.chars().all(|c| c.is_ascii_hexdigit()),
"Commit ID should be hex"
);
}
for change in &changes {
assert_eq!(
change.change_id.len(),
32,
"Change ID should be 32 characters"
);
assert!(
change.change_id.chars().all(|c| c.is_ascii_lowercase()),
"Change ID should be lowercase"
);
}
let second_commit = changes
.iter()
.find(|c| c.description_first_line == "Second commit");
assert!(
second_commit.is_some(),
"Should have a commit with 'Second commit' description. Found commits: {:?}",
changes
.iter()
.map(|c| &c.description_first_line)
.collect::<Vec<_>>()
);
}
#[test]
fn test_bookmark_parsing_strips_asterisk() {
let (_temp_dir, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path).expect("Failed to create Jujutsu instance");
jj.run_captured(&["bookmark", "create", "test-bookmark"])
.expect("Failed to create bookmark");
let sample_output_with_asterisk = "test-bookmark*\taaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\tbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n";
let mut bookmarks = Vec::new();
for line in sample_output_with_asterisk.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
let full_name = parts[0];
let full_name = full_name.strip_suffix('*').unwrap_or(full_name);
let (name, remote) = if let Some(at_pos) = full_name.rfind('@') {
let name = full_name[..at_pos].to_string();
let remote = full_name[at_pos + 1..].to_string();
(name, Some(remote))
} else {
(full_name.to_string(), None)
};
let is_local = remote.is_none();
bookmarks.push(Bookmark {
name,
commit_id: parts[1].to_string(),
change_id: parts[2].to_string(),
remote,
is_local,
has_remote: false,
});
}
}
assert_eq!(bookmarks.len(), 1, "Should parse one bookmark");
assert_eq!(
bookmarks[0].name, "test-bookmark",
"Bookmark name should NOT include asterisk, found: '{}'",
bookmarks[0].name
);
}
#[test]
fn test_get_tracked_bookmarks_empty() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path).expect("Failed to create Jujutsu instance");
let tracked = jj
.get_tracked_bookmarks("origin")
.expect("Failed to get tracked bookmarks");
assert_eq!(tracked.len(), 0, "Should have no tracked bookmarks");
}
#[test]
fn test_get_tracked_bookmarks_filters_unpushed() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path.clone()).expect("Failed to create Jujutsu instance");
run_jj_command(&repo_path, &["bookmark", "create", "local-only"])
.expect("Failed to create bookmark");
let tracked = jj
.get_tracked_bookmarks("origin")
.expect("Failed to get tracked bookmarks");
assert_eq!(
tracked.len(),
0,
"Should have no tracked bookmarks (local bookmark not pushed)"
);
}
#[test]
fn test_get_tracked_bookmarks_returns_pushed() {
let (_temp, repo_path) = create_test_repo();
let jj = Jujutsu::new(repo_path.clone()).expect("Failed to create Jujutsu instance");
run_jj_command(&repo_path, &["bookmark", "create", "feature-a"])
.expect("Failed to create bookmark");
let remote_dir = _temp.path().join("remote.git");
std::fs::create_dir(&remote_dir).expect("Failed to create remote dir");
StdCommand::new("git")
.current_dir(&remote_dir)
.args(["init", "--bare"])
.output()
.expect("Failed to init bare git repo");
run_jj_command(
&repo_path,
&[
"git",
"remote",
"add",
"origin",
remote_dir.to_str().unwrap(),
],
)
.expect("Failed to add remote");
jj.push_bookmark("feature-a", "origin")
.expect("Failed to push bookmark");
let tracked = jj
.get_tracked_bookmarks("origin")
.expect("Failed to get tracked bookmarks");
assert_eq!(tracked.len(), 1, "Should have 1 tracked bookmark");
assert_eq!(tracked[0], "feature-a", "Should track feature-a");
}
}