use std::path::{Path, PathBuf};
use processkit::ProcessRunner;
use vcs_jj::{
BookmarkName, ChangedPath, Jj, JjApi, JjFileset, OutputBudget, RevsetExpr, Rollback,
WorkspaceAdd,
};
use crate::dto::{
ChangeKind, Commit, CreateOutcome, DiffStat, FileChange, MergeProbe, OperationState,
RepoSnapshot, WorktreeInfo,
};
use crate::error::{Error, Result};
fn rev(s: &str) -> Result<RevsetExpr> {
Ok(RevsetExpr::new(s)?)
}
#[derive(Clone, Copy)]
enum Observe {
Live,
ReadOnly,
}
async fn current_branch_with<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
observe: Observe,
) -> Result<Option<String>> {
let bookmarks = match observe {
Observe::Live => jj.reachable_bookmarks(dir).await?,
Observe::ReadOnly => jj.reachable_bookmarks_ignoring_working_copy(dir).await?,
};
Ok(bookmarks.into_iter().map(|b| b.name).min())
}
pub(crate) async fn current_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Option<String>> {
current_branch_with(jj, dir, Observe::Live).await
}
pub(crate) async fn trunk<R: ProcessRunner>(jj: &Jj<R>, dir: &Path) -> Result<Option<String>> {
Ok(jj.trunk(dir).await?)
}
async fn local_branches_with<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
observe: Observe,
) -> Result<Vec<String>> {
let bookmarks = match observe {
Observe::Live => jj.bookmarks(dir).await?,
Observe::ReadOnly => jj.bookmarks_ignoring_working_copy(dir).await?,
};
Ok(bookmarks.into_iter().map(|b| b.name).collect())
}
pub(crate) async fn local_branches<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<String>> {
local_branches_with(jj, dir, Observe::Live).await
}
pub(crate) async fn local_branches_readonly<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<String>> {
local_branches_with(jj, dir, Observe::ReadOnly).await
}
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, &rev("@")?).await?)
}
pub(crate) async fn conflicted_files<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<Vec<PathBuf>> {
Ok(jj.resolve_list(dir, &rev("@")?).await?)
}
pub(crate) async fn delete_branch<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
name: &str,
) -> Result<()> {
jj.bookmark_delete(dir, &BookmarkName::new(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, &BookmarkName::new(old)?, &BookmarkName::new(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, &rev("@")?).await.map_err(Into::into)
}
pub(crate) async fn log<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
revset: &str,
max: usize,
) -> Result<Vec<Commit>> {
Ok(jj
.log(dir, &rev(revset)?, max)
.await?
.into_iter()
.map(|c| Commit::new(c.commit_id, c.description))
.collect())
}
pub(crate) async fn show_file<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
revset: &str,
path: &str,
) -> Result<String> {
Ok(jj.file_show(dir, &rev(revset)?, path).await?)
}
pub(crate) async fn show_file_within<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
revset: &str,
path: &str,
budget: OutputBudget,
) -> Result<String> {
Ok(jj
.file_show_within(dir, &rev(revset)?, path, budget)
.await?)
}
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> {
snapshot_with(jj, dir, Observe::Live).await
}
pub(crate) async fn snapshot_readonly<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
) -> Result<RepoSnapshot> {
snapshot_with(jj, dir, Observe::ReadOnly).await
}
async fn snapshot_with<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
observe: Observe,
) -> Result<RepoSnapshot> {
let row = match observe {
Observe::Live => {
jj.template_query(dir, &rev("@")?, SNAPSHOT_TEMPLATE, Some(1))
.await?
}
Observe::ReadOnly => {
jj.template_query_ignoring_working_copy(dir, &rev("@")?, 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_with(jj, dir, observe).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 {
match observe {
Observe::Live => jj.status(dir).await?.len(),
Observe::ReadOnly => jj.status_ignoring_working_copy(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: &[PathBuf],
message: &str,
) -> Result<()> {
let filesets: Vec<JjFileset> = paths
.iter()
.map(|p| JjFileset::path(p.to_string_lossy()))
.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, &BookmarkName::new(branch)?)
.await?;
Ok(())
}
pub(crate) async fn push<R: ProcessRunner>(jj: &Jj<R>, dir: &Path, branch: &str) -> Result<()> {
jj.git_push(dir, Some(BookmarkName::new(branch)?)).await?;
Ok(())
}
pub(crate) async fn checkout<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
reference: &str,
) -> Result<()> {
jj.edit(dir, &rev(reference)?).await?;
Ok(())
}
pub(crate) async fn new_child<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
reference: &str,
) -> Result<()> {
jj.new_child(dir, &rev(reference)?).await?;
Ok(())
}
pub(crate) async fn rebase<R: ProcessRunner>(jj: &Jj<R>, dir: &Path, onto: &str) -> Result<()> {
jj.rebase(dir, &rev(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 wc = rev("@")?;
let merged = jj
.new_merge(
dir,
"vcs-core try_merge probe (rolled back)",
vec![wc.clone(), rev(source)?],
)
.await;
let probe = async {
if jj.is_conflicted(dir, &wc).await? {
Ok::<_, vcs_jj::Error>(Some(jj.resolve_list(dir, &wc).await?))
} else {
Ok(None)
}
}
.await;
let rollback = jj.rollback_to(dir, &pre_op).await;
match (merged, probe) {
(Ok(()), Ok(conflicts)) => {
rollback_result(rollback)?;
Ok(match conflicts {
Some(files) => MergeProbe::Conflicts(files),
None => MergeProbe::Clean,
})
}
(Ok(()), Err(err)) => {
rollback_result(rollback)?;
Err(err.into())
}
(Err(err), _) => Err(err.into()),
}
}
fn rollback_result(rollback: Rollback) -> Result<()> {
match rollback {
Rollback::Restored | Rollback::NotAttempted => Ok(()),
diverged_or_failed => Err(Error::Rollback(diverged_or_failed)),
}
}
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(), rev(base)?, path))
.await?;
let revset = format!("{ws_name}@");
if let Err(e) = jj
.bookmark_create(dir, &BookmarkName::new(branch)?, &rev(&revset)?)
.await
{
return Err(rollback_failed_create(jj, dir, &ws_name, &abs_path, preexisting, e).await);
}
Ok(CreateOutcome::Plain)
}
async fn rollback_failed_create<R: ProcessRunner>(
jj: &Jj<R>,
dir: &Path,
ws_name: &str,
abs_path: &Path,
preexisting: bool,
cause: processkit::Error,
) -> Error {
let mut residue: Vec<String> = Vec::new();
if !preexisting
&& abs_path.exists()
&& let Err(e) = std::fs::remove_dir_all(abs_path)
{
residue.push(format!(
"the workspace directory {} could not be removed ({e})",
abs_path.display()
));
}
if let Err(e) = jj.workspace_forget(dir, ws_name).await {
residue.push(format!(
"the workspace `{ws_name}` could not be forgotten ({e})"
));
}
if residue.is_empty() {
return Error::Vcs(cause);
}
Error::Io(std::io::Error::other(format!(
"creating the worktree failed at `bookmark create` ({cause}), and the rollback \
could not fully clean up: {}. Finish the cleanup manually and retry.",
residue.join("; ")
)))
}
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).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!(
"failed to remove the worktree directory {} ({e}); the jj workspace \
`{name}` is still registered — free the directory and retry",
abs_path.display()
),
))
})?;
}
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"
);
let mut unresolved: Vec<String> = Vec::new();
for (ws, root) in workspaces.into_iter().zip(roots) {
match root {
Ok(root) => {
if normalize_for_compare(&root) == target || root == path {
return Ok(ws.name);
}
}
Err(_) => unresolved.push(ws.name),
}
}
if unresolved.is_empty() {
Err(Error::WorktreeNotFound(path.to_path_buf()))
} else {
Err(Error::Io(std::io::Error::other(format!(
"could not resolve the worktree at {}: {} registered workspace(s) did not \
resolve via `jj workspace root --name` ({}); the path may belong to one of \
them — resolve or `jj workspace forget` it manually",
path.display(),
unresolved.len(),
unresolved.join(", "),
))))
}
}
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"
);
}
#[tokio::test]
async fn create_worktree_rollback_surfaces_forget_failure() {
use processkit::testing::{Reply, ScriptedRunner};
use vcs_jj::Jj;
use vcs_testkit::TempDir;
let tmp = TempDir::new("r2-rollback-forget");
let repo = tmp.path();
let wt = repo.join("wt");
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::fail(1, "cannot forget workspace\n"),
),
});
let err = create_worktree(&jj, repo, &wt, "feature", "@")
.await
.expect_err("the bookmark-step failure must propagate");
let msg = err.to_string();
assert!(
msg.contains("could not be forgotten") && msg.contains("feature"),
"the swallowed forget failure must be reported: {msg}"
);
assert!(
!wt.exists(),
"the dir removal still ran (only the forget failed)"
);
}
struct AddCreatesFile {
inner: processkit::testing::ScriptedRunner,
path: std::path::PathBuf,
}
#[async_trait::async_trait]
impl processkit::ProcessRunner for AddCreatesFile {
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") {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&self.path, b"not a dir");
}
self.inner.output_string(command).await
}
}
#[tokio::test]
async fn create_worktree_rollback_surfaces_dir_removal_failure() {
use processkit::testing::{Reply, ScriptedRunner};
use vcs_jj::Jj;
use vcs_testkit::TempDir;
let tmp = TempDir::new("r2-rollback-rmdir");
let repo = tmp.path();
let wt = repo.join("wt");
assert!(
!wt.exists(),
"must not pre-exist (so it counts as ours to remove)"
);
let jj = Jj::with_runner(AddCreatesFile {
path: 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 err = create_worktree(&jj, repo, &wt, "feature", "@")
.await
.expect_err("the bookmark-step failure must propagate");
let msg = err.to_string();
assert!(
msg.contains("could not be removed") && msg.contains("wt"),
"the swallowed dir-removal failure must be reported: {msg}"
);
}
#[tokio::test]
async fn snapshot_readonly_ignores_working_copy_on_every_spawn() {
use processkit::testing::{RecordingRunner, Reply};
use vcs_jj::Jj;
let rec = RecordingRunner::replying(Reply::ok("abc123\t0\t0\n"));
let jj = Jj::with_runner(&rec);
snapshot_readonly(&jj, Path::new("/repo"))
.await
.expect("read-only snapshot");
let calls = rec.calls();
assert!(
calls.len() >= 3,
"template + reachable bookmarks + change-count spawns, got {}",
calls.len()
);
for c in &calls {
assert!(
c.args_str().iter().any(|a| a == "--ignore-working-copy"),
"every read-only snapshot spawn must ignore the working copy: {:?}",
c.args_str()
);
}
}
#[tokio::test]
async fn snapshot_default_does_not_ignore_working_copy() {
use processkit::testing::{RecordingRunner, Reply};
use vcs_jj::Jj;
let rec = RecordingRunner::replying(Reply::ok("abc123\t0\t0\n"));
let jj = Jj::with_runner(&rec);
snapshot(&jj, Path::new("/repo"))
.await
.expect("default snapshot");
for c in &rec.calls() {
assert!(
!c.args_str().iter().any(|a| a == "--ignore-working-copy"),
"the default snapshot must let jj snapshot the working copy: {:?}",
c.args_str()
);
}
}
#[tokio::test]
async fn tombstone_bookmark_is_not_a_live_local_branch() {
use processkit::testing::{Reply, ScriptedRunner};
use vcs_jj::Jj;
let jj = Jj::with_runner(ScriptedRunner::new().on(
["jj", "bookmark", "list"],
Reply::ok(concat!(
"1\t\t\"main\"\tabc123\n", "0\t\t\"gone\"\t\n", "1\torigin\t\"gone\"\tdeadbeef\n", )),
));
let names = local_branches(&jj, Path::new("/repo"))
.await
.expect("local_branches");
assert_eq!(
names,
vec!["main".to_string()],
"the tombstone must not appear as a local branch"
);
assert!(
branch_exists(&jj, Path::new("/repo"), "main")
.await
.expect("branch_exists main")
);
assert!(
!branch_exists(&jj, Path::new("/repo"), "gone")
.await
.expect("branch_exists gone"),
"a deleted bookmark must not report as an existing branch"
);
}
#[tokio::test]
async fn snapshot_head_and_worktree_commit_share_the_full_id() {
use processkit::testing::{Reply, ScriptedRunner};
use vcs_jj::Jj;
const FULL: &str = "abcdef0123456789abcdef0123456789abcdef01";
let jj = Jj::with_runner(
ScriptedRunner::new()
.on(
["jj", "log", "-r", "@"],
Reply::ok(format!("{FULL}\t1\t0\n")),
)
.on(
["jj", "log", "-r", "heads(::@ & bookmarks())"],
Reply::ok(format!("\"main\"\t{FULL}\n")),
)
.on(
["jj", "workspace", "list"],
Reply::ok(format!("\"default\"\t{FULL}\t\"main\"\n")),
)
.on(
[
"jj",
"--ignore-working-copy",
"workspace",
"root",
"--name",
"default",
],
Reply::ok("/repo\n"),
),
);
let snap = snapshot(&jj, Path::new("/repo")).await.expect("snapshot");
let worktrees = list_worktrees(&jj, Path::new("/repo"))
.await
.expect("worktrees");
let head = snap.head.expect("snapshot head present");
assert_eq!(
head.len(),
40,
"head must be the full oid, not a short prefix"
);
let wt_commit = worktrees[0].commit.as_deref().expect("worktree commit");
assert_eq!(
head, wt_commit,
"snapshot head and worktree commit must be the same full id"
);
}
#[tokio::test]
async fn local_branches_readonly_ignores_working_copy() {
use processkit::testing::{RecordingRunner, Reply};
use vcs_jj::Jj;
let rec = RecordingRunner::replying(Reply::ok(""));
let jj = Jj::with_runner(&rec);
local_branches_readonly(&jj, Path::new("/repo"))
.await
.expect("read-only branches");
let calls = rec.calls();
assert_eq!(calls.len(), 1, "a single `bookmark list` spawn");
assert!(
calls[0]
.args_str()
.iter()
.any(|a| a == "--ignore-working-copy"),
"read-only branch listing must ignore the working copy: {:?}",
calls[0].args_str()
);
}
}