use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
use vcs_jj::{ChangedPath, Jj, JjApi, JjFileset, WorkspaceAdd};
use crate::dto::{
ChangeKind, CreateOutcome, DiffStat, FileChange, MergeProbe, OperationState, RepoSnapshot,
WorktreeInfo,
};
use crate::error::{Error, Result};
pub(crate) async fn current_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Option<String>> {
Ok(jj
.reachable_bookmarks(dir)
.await?
.into_iter()
.map(|b| b.name)
.min())
}
pub(crate) async fn trunk<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<Option<String>> {
Ok(jj.trunk(dir).await?)
}
pub(crate) async fn local_branches<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<String>> {
Ok(jj
.bookmarks(dir)
.await?
.into_iter()
.map(|b| b.name)
.collect())
}
pub(crate) async fn branch_exists<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
name: &str,
) -> Result<bool> {
Ok(jj.bookmarks(dir).await?.iter().any(|b| b.name == name))
}
pub(crate) async fn has_uncommitted_changes<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<bool> {
if !jj.current_change(dir).await?.empty {
return Ok(true);
}
Ok(jj.is_conflicted(dir, "@").await?)
}
pub(crate) async fn conflicted_files<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<String>> {
Ok(jj.resolve_list(dir, "@").await?)
}
pub(crate) async fn delete_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
name: &str,
) -> Result<()> {
jj.bookmark_delete(dir, name).await?;
Ok(())
}
pub(crate) async fn rename_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
old: &str,
new: &str,
) -> Result<()> {
jj.bookmark_rename(dir, old, new).await?;
Ok(())
}
pub(crate) async fn changed_files<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<FileChange>> {
let entries = jj.status(dir).await?;
Ok(entries.into_iter().map(file_change_from_summary).collect())
}
pub(crate) async fn diff_stat<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<DiffStat> {
jj.diff_stat(dir, "@").await.map_err(Into::into)
}
const SNAPSHOT_TEMPLATE: &str = "commit_id ++ \"\\t\" ++ \
if(empty, \"1\", \"0\") ++ \"\\t\" ++ if(conflict, \"1\", \"0\")";
pub(crate) async fn snapshot<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<RepoSnapshot> {
let row = jj
.template_query(dir, "@", SNAPSHOT_TEMPLATE, Some(1))
.await?;
let line = row.trim_end_matches(['\r', '\n']);
let fields: Vec<&str> = line.split('\t').collect();
debug_assert_eq!(
fields.len(),
3,
"jj snapshot template arity drift (expected 3 tab fields): {line:?}"
);
let head = fields
.first()
.copied()
.filter(|s| !s.is_empty())
.map(str::to_string);
let branch = current_branch(jj, dir).await?;
let conflicted = fields.get(2) == Some(&"1");
let dirty = fields.get(1) == Some(&"0") || conflicted;
let operation = if conflicted {
OperationState::Conflict
} else {
OperationState::Clear
};
let change_count = if dirty {
jj.status(dir).await?.len()
} else {
0
};
Ok(RepoSnapshot {
head,
branch,
tracking: None,
dirty,
change_count,
conflicted,
operation,
})
}
pub(crate) async fn commit_paths<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
paths: &[String],
message: &str,
) -> Result<()> {
let filesets: Vec<JjFileset> = paths.iter().map(JjFileset::path).collect();
jj.commit_paths(dir, &filesets, message).await?;
Ok(())
}
pub(crate) async fn fetch<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<()> {
jj.git_fetch(dir).await?;
Ok(())
}
pub(crate) async fn fetch_from<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
remote: &str,
) -> Result<()> {
jj.git_fetch_from(dir, remote).await?;
Ok(())
}
pub(crate) async fn fetch_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
branch: &str,
) -> Result<()> {
jj.git_fetch_branch(dir, branch).await?;
Ok(())
}
pub(crate) async fn push<R: ProcessRunner>(jj: &Jj<R>, dir: &Path, branch: &str) -> Result<()> {
jj.git_push(dir, Some(branch.to_string())).await?;
Ok(())
}
pub(crate) async fn checkout<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
reference: &str,
) -> Result<()> {
jj.edit(dir, reference).await?;
Ok(())
}
pub(crate) async fn rebase<R: ProcessRunner>(jj: &Jj<R>, dir: &Path, onto: &str) -> Result<()> {
jj.rebase(dir, onto).await?;
Ok(())
}
pub(crate) async fn try_merge<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
source: &str,
) -> Result<MergeProbe> {
let pre_op = jj.op_head(dir).await?;
let merged = jj
.new_merge(
dir,
"vcs-core try_merge probe (rolled back)",
vec!["@".into(), source.into()],
)
.await;
let probe = async {
if jj.is_conflicted(dir, "@").await? {
Ok::<_, vcs_jj::Error>(Some(jj.resolve_list(dir, "@").await?))
} else {
Ok(None)
}
}
.await;
let restored = jj.op_restore(dir, &pre_op).await;
match (merged, probe) {
(Ok(()), Ok(conflicts)) => {
restored?;
Ok(match conflicts {
Some(files) => MergeProbe::Conflicts(files),
None => MergeProbe::Clean,
})
}
(Ok(()), Err(err)) => {
restored?;
Err(err.into())
}
(Err(err), _) => Err(err.into()),
}
}
pub(crate) async fn abort_in_progress<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<OperationState> {
in_progress_state(jj, dir).await
}
pub(crate) async fn continue_in_progress<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<OperationState> {
in_progress_state(jj, dir).await
}
pub(crate) async fn in_progress_state<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<OperationState> {
if jj.has_workingcopy_conflict(dir).await? {
Ok(OperationState::Conflict)
} else {
Ok(OperationState::Clear)
}
}
pub(crate) async fn list_worktrees<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<WorktreeInfo>> {
let workspaces = jj.workspace_list(dir).await?;
let names: Vec<String> = workspaces.iter().map(|ws| ws.name.clone()).collect();
let roots = jj.workspace_roots(dir, &names).await;
debug_assert_eq!(
names.len(),
roots.len(),
"workspace_roots must return one result per name"
);
let mut out = Vec::new();
for (ws, root) in workspaces.into_iter().zip(roots) {
let Ok(root) = root else {
continue; };
out.push(WorktreeInfo {
path: root,
branch: ws.bookmarks.into_iter().next(),
commit: (!ws.commit.is_empty()).then_some(ws.commit),
is_bare: false,
});
}
Ok(out)
}
pub(crate) async fn create_worktree<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
path: &Path,
branch: &str,
base: &str,
) -> Result<CreateOutcome> {
let ws_name = workspace_name_for(branch);
let abs_path = dir.join(path);
let preexisting = abs_path.exists();
jj.workspace_add(dir, WorkspaceAdd::new(ws_name.clone(), base, path))
.await?;
let revset = format!("{ws_name}@");
if let Err(e) = jj.bookmark_create(dir, branch, &revset).await {
if !preexisting && abs_path.exists() {
let _ = std::fs::remove_dir_all(&abs_path);
}
let _ = jj.workspace_forget(dir, &ws_name).await;
return Err(e.into());
}
Ok(CreateOutcome::Plain)
}
const DEFAULT_WORKSPACE: &str = "default";
pub(crate) async fn remove_worktree<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
path: &Path,
force: bool,
) -> Result<()> {
let abs_path = dir.join(path);
let name = workspace_name_for_path(jj, dir, &abs_path).await?;
if name == DEFAULT_WORKSPACE || abs_path.join(".jj").join("repo").is_dir() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"refusing to remove the repository's main workspace (its directory is \
the main working copy and owns the object store)",
)));
}
if !force && abs_path.exists() && !jj.current_change(&abs_path).await?.empty {
return Err(Error::Io(std::io::Error::other(
"worktree has uncommitted changes; pass force = true to remove it \
(the changes are snapshotted in jj's op log and recoverable)",
)));
}
if abs_path.exists() {
std::fs::remove_dir_all(&abs_path)?;
}
jj.workspace_forget(dir, &name).await?;
Ok(())
}
fn workspace_name_for(branch: &str) -> String {
branch
.chars()
.map(|c| match c {
'/' | '\\' | '.' | ':' | ' ' | '\t' | '\n' | '\r' => '_',
other => other,
})
.collect()
}
async fn workspace_name_for_path<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
path: &Path,
) -> Result<String> {
let target = normalize_for_compare(path);
let workspaces = jj.workspace_list(dir).await?;
let names: Vec<String> = workspaces.iter().map(|ws| ws.name.clone()).collect();
let roots = jj.workspace_roots(dir, &names).await;
debug_assert_eq!(
names.len(),
roots.len(),
"workspace_roots must return one result per name"
);
for (ws, root) in workspaces.into_iter().zip(roots) {
let Ok(root) = root else {
continue;
};
if normalize_for_compare(&root) == target || root == path {
return Ok(ws.name);
}
}
Err(Error::WorktreeNotFound(path.to_path_buf()))
}
fn normalize_for_compare(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
}
fn file_change_from_summary(entry: ChangedPath) -> FileChange {
FileChange {
kind: change_kind_from_status(entry.status),
path: entry.path,
old_path: entry.old_path,
}
}
fn change_kind_from_status(status: char) -> ChangeKind {
match status {
'A' | 'C' => ChangeKind::Added,
'D' => ChangeKind::Deleted,
'R' => ChangeKind::Renamed,
_ => ChangeKind::Modified,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_name_substitutes_invalid_chars() {
assert_eq!(workspace_name_for("feature/x.y"), "feature_x_y");
assert_eq!(workspace_name_for("plain"), "plain");
}
#[test]
fn summary_status_maps_to_change_kind() {
assert_eq!(change_kind_from_status('M'), ChangeKind::Modified);
assert_eq!(change_kind_from_status('A'), ChangeKind::Added);
assert_eq!(change_kind_from_status('C'), ChangeKind::Added);
assert_eq!(change_kind_from_status('D'), ChangeKind::Deleted);
assert_eq!(change_kind_from_status('R'), ChangeKind::Renamed);
}
struct AddCreatesDir {
inner: processkit::testing::ScriptedRunner,
dir: std::path::PathBuf,
}
#[async_trait::async_trait]
impl processkit::ProcessRunner for AddCreatesDir {
async fn output_string(
&self,
command: &processkit::Command,
) -> processkit::Result<processkit::ProcessResult<String>> {
let args: Vec<String> = command
.arguments()
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
if args.iter().any(|a| a == "workspace") && args.iter().any(|a| a == "add") {
let _ = std::fs::create_dir_all(&self.dir);
}
self.inner.output_string(command).await
}
}
#[tokio::test]
async fn create_worktree_rolls_back_when_bookmark_step_fails() {
use processkit::testing::{Reply, ScriptedRunner};
use vcs_jj::Jj;
use vcs_testkit::TempDir;
let tmp = TempDir::new("r1-worktree-rollback");
let repo = tmp.path();
let wt = repo.join("wt");
assert!(!wt.exists(), "the worktree dir must not pre-exist");
let jj = Jj::with_runner(AddCreatesDir {
dir: wt.clone(),
inner: ScriptedRunner::new()
.on(["jj", "workspace", "add"], Reply::ok(""))
.on(
["jj", "bookmark", "create"],
Reply::fail(1, "bookmark already exists\n"),
)
.on(["jj", "workspace", "forget"], Reply::ok("")),
});
let result = create_worktree(&jj, repo, &wt, "feature", "@").await;
assert!(result.is_err(), "the bookmark-step failure must propagate");
assert!(
!wt.exists(),
"the worktree dir that `workspace add` created must be cleaned up on rollback"
);
}
#[tokio::test]
async fn create_worktree_rollback_spares_preexisting_dir() {
use processkit::testing::{Reply, ScriptedRunner};
use vcs_jj::Jj;
use vcs_testkit::TempDir;
let tmp = TempDir::new("r1-worktree-spare");
let repo = tmp.path();
let wt = repo.join("existing");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join("keep.txt"), b"mine").unwrap();
let jj = Jj::with_runner(
ScriptedRunner::new()
.on(["jj", "workspace", "add"], Reply::ok(""))
.on(
["jj", "bookmark", "create"],
Reply::fail(1, "bookmark already exists\n"),
)
.on(["jj", "workspace", "forget"], Reply::ok("")),
);
let result = create_worktree(&jj, repo, &wt, "feature", "@").await;
assert!(result.is_err(), "the bookmark-step failure must propagate");
assert!(
wt.join("keep.txt").exists(),
"a pre-existing directory must survive the rollback untouched"
);
}
#[tokio::test]
async fn create_worktree_resolves_relative_path_against_dir() {
use processkit::testing::{Reply, ScriptedRunner};
use std::path::Path;
use vcs_jj::Jj;
use vcs_testkit::TempDir;
let tmp = TempDir::new("r1-worktree-relpath");
let repo = tmp.path(); let rel = Path::new("rel-wt");
let resolved = repo.join(rel); assert!(!resolved.exists());
let jj = Jj::with_runner(AddCreatesDir {
dir: resolved.clone(), inner: ScriptedRunner::new()
.on(["jj", "workspace", "add"], Reply::ok(""))
.on(
["jj", "bookmark", "create"],
Reply::fail(1, "bookmark already exists\n"),
)
.on(["jj", "workspace", "forget"], Reply::ok("")),
});
let result = create_worktree(&jj, repo, rel, "feature", "@").await;
assert!(result.is_err(), "the bookmark-step failure must propagate");
assert!(
!resolved.exists(),
"the rollback must remove dir/<rel>, the location jj created"
);
}
}