#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use processkit::{JobRunner, ProcessRunner};
use vcs_git::{Git, GitAt};
use vcs_jj::{Jj, JjAt};
mod dto;
mod error;
mod git_backend;
mod jj_backend;
pub use dto::{
BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, MergeProbe, OperationState,
RepoSnapshot, WorktreeInfo,
};
pub use error::{Error, Result};
pub use vcs_git;
pub use vcs_jj;
#[cfg(feature = "cancellation")]
#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
pub use processkit::CancellationToken;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Located {
pub kind: BackendKind,
pub root: PathBuf,
}
pub fn detect(start: &Path) -> Option<Located> {
let mut current = Some(start);
while let Some(dir) = current {
if dir.join(".jj").is_dir() {
return Some(Located {
kind: BackendKind::Jj,
root: dir.to_path_buf(),
});
}
if dir.join(".git").exists() {
return Some(Located {
kind: BackendKind::Git,
root: dir.to_path_buf(),
});
}
current = dir.parent();
}
None
}
enum Backend<R: ProcessRunner> {
Git(Arc<Git<R>>),
Jj(Arc<Jj<R>>),
}
impl<R: ProcessRunner> Backend<R> {
fn shared(&self) -> Self {
match self {
Backend::Git(g) => Backend::Git(Arc::clone(g)),
Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
}
}
}
pub struct Repo<R: ProcessRunner = JobRunner> {
root: PathBuf,
cwd: PathBuf,
backend: Backend<R>,
}
impl Repo<JobRunner> {
pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
let dir = std::path::absolute(dir.as_ref())?;
let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
let backend = match located.kind {
BackendKind::Git => Backend::Git(Arc::new(Git::new())),
BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
};
Ok(Repo {
root: located.root,
cwd: dir,
backend,
})
}
}
impl<R: ProcessRunner> Repo<R> {
pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
Repo {
root: root.into(),
cwd: cwd.into(),
backend: Backend::Git(Arc::new(client)),
}
}
pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
Repo {
root: root.into(),
cwd: cwd.into(),
backend: Backend::Jj(Arc::new(client)),
}
}
pub fn kind(&self) -> BackendKind {
match &self.backend {
Backend::Git(_) => BackendKind::Git,
Backend::Jj(_) => BackendKind::Jj,
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
Repo {
root: self.root.clone(),
cwd: dir.into(),
backend: self.backend.shared(),
}
}
pub fn git(&self) -> Option<&Git<R>> {
match &self.backend {
Backend::Git(g) => Some(g.as_ref()),
Backend::Jj(_) => None,
}
}
pub fn jj(&self) -> Option<&Jj<R>> {
match &self.backend {
Backend::Jj(j) => Some(j.as_ref()),
Backend::Git(_) => None,
}
}
pub fn git_at(&self) -> Option<GitAt<'_, R>> {
match &self.backend {
Backend::Git(g) => Some(g.at(&self.cwd)),
Backend::Jj(_) => None,
}
}
pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
match &self.backend {
Backend::Jj(j) => Some(j.at(&self.cwd)),
Backend::Git(_) => None,
}
}
pub async fn current_branch(&self) -> Result<Option<String>> {
match &self.backend {
Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
}
}
pub async fn trunk(&self) -> Result<Option<String>> {
let native = match &self.backend {
Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
};
if native.is_some() {
return Ok(native);
}
for candidate in ["main", "master"] {
if self.branch_exists(candidate).await? {
return Ok(Some(candidate.to_string()));
}
}
Ok(None)
}
pub async fn local_branches(&self) -> Result<Vec<String>> {
match &self.backend {
Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
}
}
pub async fn branch_exists(&self, name: &str) -> Result<bool> {
match &self.backend {
Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
}
}
pub async fn has_uncommitted_changes(&self) -> Result<bool> {
match &self.backend {
Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
}
}
pub async fn has_tracked_changes(&self) -> Result<bool> {
match &self.backend {
Backend::Git(g) => git_backend::has_tracked_changes(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
}
}
pub async fn conflicted_files(&self) -> Result<Vec<String>> {
match &self.backend {
Backend::Git(g) => git_backend::conflicted_files(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::conflicted_files(j, &self.cwd).await,
}
}
pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::delete_branch(g, &self.cwd, name, force).await,
Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, name).await,
}
}
pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
}
}
pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
match &self.backend {
Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
}
}
pub async fn diff_stat(&self) -> Result<DiffStat> {
match &self.backend {
Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
}
}
pub async fn snapshot(&self) -> Result<RepoSnapshot> {
match &self.backend {
Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::snapshot(j, &self.cwd).await,
}
}
pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
if paths.is_empty() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"commit_paths requires at least one path: an empty set would error \
on git but commit the entire working copy on jj",
)));
}
match &self.backend {
Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
}
}
pub async fn fetch(&self) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
}
}
pub async fn fetch_from(&self, remote: &str) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::fetch_from(g, &self.cwd, remote).await,
Backend::Jj(j) => jj_backend::fetch_from(j, &self.cwd, remote).await,
}
}
pub async fn fetch_remote_branch(&self, branch: &str) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::fetch_remote_branch(g, &self.cwd, branch).await,
Backend::Jj(j) => jj_backend::fetch_remote_branch(j, &self.cwd, branch).await,
}
}
pub async fn push(&self, branch: &str) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::push(g, &self.cwd, branch).await,
Backend::Jj(j) => jj_backend::push(j, &self.cwd, branch).await,
}
}
pub async fn checkout(&self, reference: &str) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
}
}
pub async fn rebase(&self, onto: &str) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
}
}
pub async fn try_merge(&self, source: &str) -> Result<MergeProbe> {
match &self.backend {
Backend::Git(g) => git_backend::try_merge(g, &self.cwd, source).await,
Backend::Jj(j) => jj_backend::try_merge(j, &self.cwd, source).await,
}
}
pub async fn abort_in_progress(&self) -> Result<OperationState> {
match &self.backend {
Backend::Git(g) => git_backend::abort_in_progress(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::abort_in_progress(j, &self.cwd).await,
}
}
pub async fn continue_in_progress(&self) -> Result<OperationState> {
match &self.backend {
Backend::Git(g) => git_backend::continue_in_progress(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::continue_in_progress(j, &self.cwd).await,
}
}
pub async fn in_progress_state(&self) -> Result<OperationState> {
match &self.backend {
Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
}
}
pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
match &self.backend {
Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
}
}
pub async fn create_worktree(
&self,
path: &Path,
branch: &str,
base: &str,
) -> Result<CreateOutcome> {
match &self.backend {
Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
}
}
pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
match &self.backend {
Backend::Git(g) => git_backend::remove_worktree(g, &self.cwd, path, force).await,
Backend::Jj(j) => jj_backend::remove_worktree(j, &self.cwd, path, force).await,
}
}
pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
match &self.backend {
Backend::Git(_) => {
vcs_git::blocking::worktree_remove(&self.cwd, path, true).map_err(Error::Io)
}
Backend::Jj(_) => {
match vcs_jj::blocking::workspace_name_for_path(&self.cwd, path) {
Some(name) => {
let _ = std::fs::remove_dir_all(path);
vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
}
None => Ok(()),
}
}
}
}
}
macro_rules! facade_trait {
(
$(#[doc = $tdoc:expr])*
trait $Trait:ident for $Ty:ident;
sync {
$( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
}
async {
$( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
}
) => {
$(#[doc = $tdoc])*
#[async_trait::async_trait]
pub trait $Trait: Send + Sync {
$(
#[doc = $sdoc]
fn $sn(&self, $($sa: $sat),*) -> $sr;
)*
$(
#[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
async fn $an(&self, $($aa: $aat),*) -> $ar;
)*
}
#[async_trait::async_trait]
impl<R: ProcessRunner> $Trait for $Ty<R> {
$(
fn $sn(&self, $($sa: $sat),*) -> $sr {
self.$sn($($sa),*)
}
)*
$(
async fn $an(&self, $($aa: $aat),*) -> $ar {
self.$an($($aa),*).await
}
)*
}
};
}
facade_trait! {
trait VcsRepo for Repo;
sync {
#[doc = "Which backend drives this handle."]
fn kind() -> BackendKind;
#[doc = "The repository root detected at open time."]
fn root() -> &Path;
#[doc = "The directory operations run against."]
fn cwd() -> &Path;
#[doc = "See [`Repo::cleanup_worktree_blocking`]."]
fn cleanup_worktree_blocking(path: &Path) -> Result<()>;
}
async {
fn current_branch() -> Result<Option<String>>;
fn trunk() -> Result<Option<String>>;
fn local_branches() -> Result<Vec<String>>;
fn branch_exists(name: &str) -> Result<bool>;
fn has_uncommitted_changes() -> Result<bool>;
fn has_tracked_changes() -> Result<bool>;
fn conflicted_files() -> Result<Vec<String>>;
fn delete_branch(name: &str, force: bool) -> Result<()>;
fn rename_branch(old: &str, new: &str) -> Result<()>;
fn changed_files() -> Result<Vec<FileChange>>;
fn diff_stat() -> Result<DiffStat>;
fn snapshot() -> Result<RepoSnapshot>;
fn commit_paths(paths: &[String], message: &str) -> Result<()>;
fn fetch() -> Result<()>;
fn fetch_from(remote: &str) -> Result<()>;
fn fetch_remote_branch(branch: &str) -> Result<()>;
fn push(branch: &str) -> Result<()>;
fn checkout(reference: &str) -> Result<()>;
fn rebase(onto: &str) -> Result<()>;
fn try_merge(source: &str) -> Result<MergeProbe>;
fn abort_in_progress() -> Result<OperationState>;
fn continue_in_progress() -> Result<OperationState>;
fn in_progress_state() -> Result<OperationState>;
fn list_worktrees() -> Result<Vec<WorktreeInfo>>;
fn create_worktree(path: &Path, branch: &str, base: &str) -> Result<CreateOutcome>;
fn remove_worktree(path: &Path, force: bool) -> Result<()>;
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{Reply, ScriptedRunner};
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("vcs-core-{tag}-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create temp dir");
TempDir(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn detect_finds_git_and_jj_and_prefers_jj() {
let tmp = TempDir::new("detect");
let root = tmp.path();
std::fs::create_dir_all(root.join(".git")).unwrap();
let located = detect(root).expect("git detected");
assert_eq!(located.kind, BackendKind::Git);
assert_eq!(located.root, root);
std::fs::create_dir_all(root.join(".jj")).unwrap();
assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
}
#[test]
fn detect_walks_up_to_ancestor() {
let tmp = TempDir::new("walkup");
let root = tmp.path();
std::fs::create_dir_all(root.join(".git")).unwrap();
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let located = detect(&nested).expect("found via ancestor walk");
assert_eq!(located.kind, BackendKind::Git);
assert_eq!(located.root, root);
}
#[test]
fn detect_returns_none_outside_repo() {
let tmp = TempDir::new("norepo");
assert!(detect(tmp.path()).is_none());
}
fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
Repo::from_git("/repo", "/repo", Git::with_runner(runner))
}
fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
}
#[tokio::test]
async fn git_snapshot_combines_v2_status_and_op_state() {
let v2 = concat!(
"# branch.oid abc123\0",
"# branch.head main\0",
"# branch.upstream origin/main\0",
"# branch.ab +2 -0\0",
"1 .M N... 100644 100644 100644 1 2 a.rs\0",
"? new.txt\0",
);
let gitdir = TempDir::new("snap-git");
let repo = git_repo(
ScriptedRunner::new()
.on(["status", "--porcelain=v2"], Reply::ok(v2))
.on(
["rev-parse", "--git-dir"],
Reply::ok(gitdir.path().to_str().unwrap()),
),
);
let s = repo.snapshot().await.unwrap();
assert_eq!(s.branch.as_deref(), Some("main"));
assert_eq!(s.upstream.as_deref(), Some("origin/main"));
assert_eq!((s.ahead, s.behind), (Some(2), Some(0)));
assert!(s.dirty);
assert_eq!(s.change_count, 2, "1 tracked + 1 untracked");
assert!(!s.conflicted);
assert_eq!(s.operation, OperationState::Clear);
}
#[tokio::test]
async fn jj_snapshot_from_template_with_change_count() {
let repo = jj_repo(
ScriptedRunner::new()
.on(["log"], Reply::ok("deadbeef\tmain\t0\t1\n")) .on(["diff"], Reply::ok("M a.rs\nA b.rs\n")), );
let s = repo.snapshot().await.unwrap();
assert_eq!(s.head.as_deref(), Some("deadbeef"));
assert_eq!(s.branch.as_deref(), Some("main"));
assert!(s.dirty);
assert_eq!(s.change_count, 2);
assert!(s.conflicted);
assert_eq!(s.operation, OperationState::Conflict);
assert_eq!(s.upstream, None);
assert_eq!((s.ahead, s.behind), (None, None));
}
#[tokio::test]
async fn jj_snapshot_clean_skips_change_count() {
let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("c0ffee\t\t1\t0\n")));
let s = repo.snapshot().await.unwrap();
assert_eq!(s.head.as_deref(), Some("c0ffee"));
assert_eq!(s.branch, None, "no bookmark");
assert!(!s.dirty);
assert_eq!(s.change_count, 0);
assert!(!s.conflicted);
assert_eq!(s.operation, OperationState::Clear);
}
#[tokio::test]
async fn jj_list_worktrees_batches_root_lookups() {
let repo = jj_repo(
ScriptedRunner::new()
.on(
["workspace", "list"],
Reply::ok("default\tc0ffee\tmain\nws1\tdecaf0\t\n"),
)
.on(
["workspace", "root", "--name", "default"],
Reply::ok("/repo\n"),
)
.on(
["workspace", "root", "--name", "ws1"],
Reply::ok("/repo/ws1\n"),
),
);
let worktrees = repo.list_worktrees().await.expect("list_worktrees");
assert_eq!(worktrees.len(), 2);
assert_eq!(worktrees[0].path, Path::new("/repo"));
assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
assert_eq!(worktrees[1].path, Path::new("/repo/ws1"));
assert_eq!(worktrees[1].branch, None);
}
#[tokio::test]
async fn jj_list_worktrees_skips_unresolvable_root() {
let repo = jj_repo(
ScriptedRunner::new()
.on(
["workspace", "list"],
Reply::ok("default\tc0ffee\tmain\ngone\tdecaf0\t\n"),
)
.on(
["workspace", "root", "--name", "default"],
Reply::ok("/repo\n"),
)
.on(
["workspace", "root", "--name", "gone"],
Reply::fail(1, "Error: No such workspace"),
),
);
let worktrees = repo.list_worktrees().await.expect("list_worktrees");
assert_eq!(worktrees.len(), 1, "the unresolvable workspace is skipped");
assert_eq!(worktrees[0].path, Path::new("/repo"));
}
#[tokio::test]
async fn kind_and_escape_hatches_reflect_backend() {
let repo = git_repo(ScriptedRunner::new());
assert_eq!(repo.kind(), BackendKind::Git);
assert!(repo.git().is_some());
assert!(repo.jj().is_none());
}
#[tokio::test]
async fn bound_views_reflect_backend_and_cwd() {
let git = git_repo(ScriptedRunner::new());
assert!(git.git_at().is_some());
assert!(git.jj_at().is_none());
assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
let jj = jj_repo(ScriptedRunner::new());
assert!(jj.jj_at().is_some());
assert!(jj.git_at().is_none());
}
#[tokio::test]
async fn current_branch_maps_detached_head_to_none() {
let named = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
assert_eq!(
named.current_branch().await.unwrap().as_deref(),
Some("main")
);
let detached = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("HEAD\n")));
assert!(detached.current_branch().await.unwrap().is_none());
}
#[tokio::test]
async fn changed_files_maps_git_status() {
let repo = git_repo(ScriptedRunner::new().on(
["status"],
Reply::ok(" M a.rs\0?? b.rs\0R new.rs\0old.rs\0"),
));
let changes = repo.changed_files().await.unwrap();
assert_eq!(changes.len(), 3);
assert_eq!(changes[0].kind, ChangeKind::Modified);
assert_eq!(changes[1].kind, ChangeKind::Added);
assert_eq!(changes[2].kind, ChangeKind::Renamed);
assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
}
#[tokio::test]
async fn local_branches_maps_git_branch_output() {
let repo = git_repo(ScriptedRunner::new().on(["branch"], Reply::ok("* main\n feat\n")));
assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
}
#[tokio::test]
async fn branch_exists_reads_show_ref_exit() {
let yes = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
assert!(yes.branch_exists("main").await.unwrap());
let no = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
assert!(!no.branch_exists("nope").await.unwrap());
}
#[tokio::test]
async fn has_uncommitted_changes_reflects_status() {
let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
assert!(dirty.has_uncommitted_changes().await.unwrap());
let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
assert!(!clean.has_uncommitted_changes().await.unwrap());
}
#[tokio::test]
async fn at_rebinds_cwd_and_shares_backend() {
let repo = git_repo(ScriptedRunner::new());
let moved = repo.at("/repo/sub");
assert_eq!(moved.cwd(), Path::new("/repo/sub"));
assert_eq!(moved.root(), Path::new("/repo"));
assert_eq!(moved.kind(), BackendKind::Git);
}
#[tokio::test]
async fn jj_kind_and_escape_hatches_reflect_backend() {
let repo = jj_repo(ScriptedRunner::new());
assert_eq!(repo.kind(), BackendKind::Jj);
assert!(repo.jj().is_some() && repo.git().is_none());
}
#[tokio::test]
async fn jj_current_branch_reads_bookmark() {
let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
assert_eq!(
repo.current_branch().await.unwrap().as_deref(),
Some("main")
);
}
#[tokio::test]
async fn jj_local_branches_maps_bookmark_list() {
let repo = jj_repo(ScriptedRunner::new().on(
["bookmark", "list"],
Reply::ok("main: chg cmt desc\nfeat: c2 m2 d2\n"),
));
assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
}
#[tokio::test]
async fn jj_branch_exists_scans_bookmarks() {
let repo = jj_repo(
ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
);
assert!(repo.branch_exists("main").await.unwrap());
let repo2 = jj_repo(
ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
);
assert!(!repo2.branch_exists("missing").await.unwrap());
}
#[tokio::test]
async fn jj_has_uncommitted_changes_reads_empty_flag() {
let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
assert!(dirty.has_uncommitted_changes().await.unwrap());
let clean = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\ttrue\t\n")));
assert!(!clean.has_uncommitted_changes().await.unwrap());
}
#[tokio::test]
async fn jj_changed_files_maps_diff_summary() {
let repo = jj_repo(
ScriptedRunner::new().on(["diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
);
let changes = repo.changed_files().await.unwrap();
assert_eq!(changes.len(), 3);
assert_eq!(changes[0].kind, ChangeKind::Modified);
assert_eq!(changes[1].kind, ChangeKind::Added);
assert_eq!(changes[2].kind, ChangeKind::Deleted);
assert!(changes.iter().all(|c| c.old_path.is_none()));
}
#[tokio::test]
async fn jj_changed_files_populates_rename_old_path() {
let repo =
jj_repo(ScriptedRunner::new().on(["diff"], Reply::ok("R src/{old.rs => new.rs}\n")));
let changes = repo.changed_files().await.unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, ChangeKind::Renamed);
assert_eq!(changes[0].path, "src/new.rs");
assert_eq!(changes[0].old_path.as_deref(), Some("src/old.rs"));
}
#[tokio::test]
async fn commit_paths_refuses_an_empty_path_set() {
for repo in [
git_repo(ScriptedRunner::new()),
jj_repo(ScriptedRunner::new()),
] {
let err = repo
.commit_paths(&[], "msg")
.await
.expect_err("empty paths must be refused");
assert!(
err.to_string().contains("at least one path"),
"unexpected error: {err}"
);
}
}
#[tokio::test]
async fn jj_rename_branch_builds_bookmark_rename() {
use processkit::RecordingRunner;
let rec = RecordingRunner::replying(Reply::ok(""));
let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
repo.rename_branch("old", "new").await.unwrap();
assert_eq!(
rec.only_call().args_str(),
["bookmark", "rename", "old", "new", "--color", "never"]
);
}
#[tokio::test]
async fn checkout_dispatches_per_backend() {
use processkit::RecordingRunner;
let grec = RecordingRunner::replying(Reply::ok(""));
Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
.checkout("feat")
.await
.unwrap();
assert_eq!(grec.only_call().args_str(), ["checkout", "feat"]);
let jrec = RecordingRunner::replying(Reply::ok(""));
Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
.checkout("feat")
.await
.unwrap();
assert_eq!(
jrec.only_call().args_str(),
["edit", "feat", "--color", "never"]
);
}
#[tokio::test]
async fn fetch_remote_branch_dispatches_per_backend() {
use processkit::RecordingRunner;
let grec = RecordingRunner::replying(Reply::ok(""));
Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
.fetch_remote_branch("main")
.await
.unwrap();
assert!(
grec.only_call()
.args_str()
.starts_with(&["fetch".to_string()])
);
let jrec = RecordingRunner::replying(Reply::ok(""));
Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
.fetch_remote_branch("main")
.await
.unwrap();
let args = jrec.only_call().args_str();
assert_eq!(&args[..2], &["git", "fetch"]);
}
#[tokio::test]
async fn push_dispatches_per_backend() {
use processkit::RecordingRunner;
let grec = RecordingRunner::replying(Reply::ok(""));
Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
.push("feature")
.await
.unwrap();
assert_eq!(
grec.only_call().args_str(),
["push", "-u", "origin", "feature"]
);
let jrec = RecordingRunner::replying(Reply::ok(""));
Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
.push("feature")
.await
.unwrap();
let args = jrec.only_call().args_str();
assert_eq!(&args[..4], &["git", "push", "-b", "feature"]);
}
#[tokio::test]
async fn push_flag_like_branch_follows_guard_convention() {
use processkit::RecordingRunner;
let grec = RecordingRunner::replying(Reply::ok(""));
let err = Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
.push("--force")
.await
.unwrap_err();
assert!(
matches!(err, Error::Vcs(processkit::Error::Spawn { .. })),
"got: {err:?}"
);
assert_eq!(grec.calls().len(), 0, "no process must have spawned");
let jrec = RecordingRunner::replying(Reply::ok(""));
Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
.push("--force")
.await
.expect("jj path spawns; the value rides -b verbatim");
assert_eq!(
&jrec.only_call().args_str()[..4],
&["git", "push", "-b", "--force"],
"the flag-like value must ride the -b flag-VALUE slot, not become argv"
);
}
#[tokio::test]
async fn fetch_from_names_the_remote_on_both_backends() {
use processkit::RecordingRunner;
let grec = RecordingRunner::replying(Reply::ok(""));
Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
.fetch_from("upstream")
.await
.unwrap();
assert_eq!(
grec.only_call().args_str(),
["fetch", "--quiet", "upstream"]
);
let jrec = RecordingRunner::replying(Reply::ok(""));
Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
.fetch_from("upstream")
.await
.unwrap();
let args = jrec.only_call().args_str();
assert_eq!(&args[..4], &["git", "fetch", "--remote", "upstream"]);
}
#[tokio::test]
async fn git_has_tracked_changes_ignores_untracked() {
let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
assert!(dirty.has_tracked_changes().await.unwrap());
let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
assert!(!clean.has_tracked_changes().await.unwrap());
}
#[tokio::test]
async fn jj_has_tracked_changes_follows_working_copy() {
let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
assert!(dirty.has_tracked_changes().await.unwrap());
}
#[tokio::test]
async fn conflicted_files_dispatches_per_backend() {
let git = git_repo(ScriptedRunner::new().on(["diff"], Reply::ok("a.rs\0b dir/c.rs\0")));
assert_eq!(
git.conflicted_files().await.unwrap(),
["a.rs", "b dir/c.rs"]
);
let jj =
jj_repo(ScriptedRunner::new().on(["resolve"], Reply::ok("a.rs 2-sided conflict\n")));
assert_eq!(jj.conflicted_files().await.unwrap(), ["a.rs"]);
let clean = jj_repo(ScriptedRunner::new().on(
["resolve"],
Reply::fail(2, "Error: No conflicts found at this revision"),
));
assert!(clean.conflicted_files().await.unwrap().is_empty());
}
#[test]
fn merge_probe_is_clean() {
assert!(MergeProbe::Clean.is_clean());
assert!(!MergeProbe::Conflicts(vec!["a.rs".into()]).is_clean());
}
#[tokio::test]
async fn git_try_merge_reports_clean_and_skips_needless_abort() {
use processkit::RecordingRunner;
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["merge"], Reply::ok("Already up to date.\n"))
.on(["rev-parse"], Reply::ok("/vcs-core-no-such-git-dir")),
);
let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
assert!(
rec.calls()
.iter()
.all(|c| !c.args_str().contains(&"--abort".to_string())),
"no merge to abort"
);
}
#[tokio::test]
async fn git_try_merge_collects_conflicts_then_aborts() {
use processkit::RecordingRunner;
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["merge", "--abort"], Reply::ok(""))
.on(
["merge"],
Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
)
.on(["diff"], Reply::ok("a.rs\0")),
);
let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
assert_eq!(
repo.try_merge("other").await.unwrap(),
MergeProbe::Conflicts(vec!["a.rs".to_string()])
);
let calls = rec.calls();
let diff_pos = calls.iter().position(|c| c.args_str()[0] == "diff");
let abort_pos = calls
.iter()
.position(|c| c.args_str().contains(&"--abort".to_string()));
assert!(diff_pos.unwrap() < abort_pos.unwrap(), "{calls:?}");
}
#[tokio::test]
async fn git_try_merge_propagates_abort_failure() {
let tmp = TempDir::new("probe-abort");
std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
let repo = git_repo(
ScriptedRunner::new()
.on(
["merge", "--abort"],
Reply::fail(128, "fatal: cannot abort"),
)
.on(["merge"], Reply::ok(""))
.on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap())),
);
assert!(repo.try_merge("other").await.is_err());
}
#[tokio::test]
async fn jj_try_merge_probes_and_restores() {
use processkit::RecordingRunner;
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["op", "log"], Reply::ok("op42\n"))
.on(["op", "restore"], Reply::ok(""))
.on(["new"], Reply::ok(""))
.on(["log"], Reply::ok("1\n")) .on(["resolve"], Reply::ok("a.rs 2-sided conflict\n")),
);
let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
assert_eq!(
repo.try_merge("feature").await.unwrap(),
MergeProbe::Conflicts(vec!["a.rs".to_string()])
);
let calls = rec.calls();
assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
assert_eq!(calls[1].args_str()[0], "new");
let last = calls.last().unwrap().args_str();
assert_eq!(last[..3], ["op", "restore", "op42"]);
}
#[tokio::test]
async fn jj_try_merge_clean_and_restore_failure() {
let clean = jj_repo(
ScriptedRunner::new()
.on(["op", "log"], Reply::ok("op42\n"))
.on(["op", "restore"], Reply::ok(""))
.on(["new"], Reply::ok(""))
.on(["log"], Reply::ok("0\n")),
);
assert_eq!(clean.try_merge("feature").await.unwrap(), MergeProbe::Clean);
let broken = jj_repo(
ScriptedRunner::new()
.on(["op", "log"], Reply::ok("op42\n"))
.on(["op", "restore"], Reply::fail(1, "op not found"))
.on(["new"], Reply::ok(""))
.on(["log"], Reply::ok("0\n")),
);
assert!(broken.try_merge("feature").await.is_err());
}
#[tokio::test]
async fn git_continue_blocked_by_conflicts_does_not_act() {
use processkit::RecordingRunner;
let rec = RecordingRunner::new(ScriptedRunner::new().on(["diff"], Reply::ok("a.rs\0")));
let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
assert_eq!(
repo.continue_in_progress().await.unwrap(),
OperationState::Conflict
);
assert!(
rec.calls().iter().all(|c| c.args_str()[0] == "diff"),
"only the conflict probe may run: {:?}",
rec.calls()
);
}
#[tokio::test]
async fn git_continue_maps_rebase_re_conflict() {
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicBool, Ordering};
let tmp = TempDir::new("rebase-restop");
std::fs::create_dir_all(tmp.path().join("rebase-merge")).unwrap();
let seen_first_diff = StdArc::new(AtomicBool::new(false));
let flag = StdArc::clone(&seen_first_diff);
let repo = git_repo(
ScriptedRunner::new()
.when(
move |cmd| {
cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
&& flag.swap(true, Ordering::SeqCst)
},
Reply::ok("a.rs\0"),
)
.on(["diff"], Reply::ok(""))
.on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap()))
.on(
["rebase", "--continue"],
Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
),
);
assert_eq!(
repo.continue_in_progress().await.unwrap(),
OperationState::Conflict
);
}
#[tokio::test]
async fn git_abort_dispatches_on_merge_in_progress() {
use processkit::RecordingRunner;
let tmp = TempDir::new("abort");
std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap()))
.on(["merge", "--abort"], Reply::ok("")),
);
let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
repo.abort_in_progress().await.unwrap();
assert!(
rec.calls()
.iter()
.any(|c| c.args_str() == ["merge", "--abort"]),
"{:?}",
rec.calls()
);
}
#[tokio::test]
async fn git_in_progress_state_maps_merge_and_rebase() {
let merging = TempDir::new("inprog-merge");
std::fs::write(merging.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
let merge_repo = Repo::from_git(
"/repo",
"/repo",
Git::with_runner(
ScriptedRunner::new()
.on(["rev-parse"], Reply::ok(merging.path().to_str().unwrap())),
),
);
assert_eq!(
merge_repo.in_progress_state().await.unwrap(),
OperationState::Merge
);
let rebasing = TempDir::new("inprog-rebase");
std::fs::create_dir_all(rebasing.path().join("rebase-merge")).unwrap();
let rebase_repo = Repo::from_git(
"/repo",
"/repo",
Git::with_runner(
ScriptedRunner::new()
.on(["rev-parse"], Reply::ok(rebasing.path().to_str().unwrap())),
),
);
assert_eq!(
rebase_repo.in_progress_state().await.unwrap(),
OperationState::Rebase
);
}
#[tokio::test]
async fn git_diff_stat_unborn_uses_empty_tree() {
use processkit::RecordingRunner;
let rec = RecordingRunner::new(
ScriptedRunner::new()
.on(["rev-parse"], Reply::fail(1, "")) .on(
["diff", "--shortstat"],
Reply::ok(" 1 file changed, 2 insertions(+)\n"),
),
);
let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
let stat = repo.diff_stat().await.unwrap();
assert_eq!(stat.insertions, 2);
assert!(
rec.calls()
.iter()
.any(|c| c.args_str() == ["diff", "--shortstat", vcs_git::EMPTY_TREE]),
"diff_stat should target the empty tree on an unborn repo: {:?}",
rec.calls()
);
}
#[tokio::test]
async fn jj_abort_and_continue_are_reporting_noops() {
let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
assert_eq!(
conflicted.abort_in_progress().await.unwrap(),
OperationState::Conflict
);
let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
assert_eq!(
clear.continue_in_progress().await.unwrap(),
OperationState::Clear
);
}
#[tokio::test]
async fn jj_in_progress_state_maps_conflict() {
let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
assert_eq!(
conflicted.in_progress_state().await.unwrap(),
OperationState::Conflict
);
let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
assert_eq!(
clear.in_progress_state().await.unwrap(),
OperationState::Clear
);
}
#[tokio::test]
async fn vcs_repo_trait_object_dispatches() {
let repo = git_repo(
ScriptedRunner::new()
.on(["rev-parse"], Reply::ok("main\n"))
.on(["show-ref"], Reply::ok("")),
);
let dynamic: &dyn VcsRepo = &repo;
assert_eq!(dynamic.kind(), BackendKind::Git);
assert_eq!(
dynamic.current_branch().await.unwrap().as_deref(),
Some("main")
);
assert!(dynamic.branch_exists("main").await.unwrap());
}
#[tokio::test]
async fn trunk_falls_back_to_main() {
let repo = git_repo(
ScriptedRunner::new()
.on(["symbolic-ref"], Reply::fail(1, "")) .on(["show-ref"], Reply::ok("")), );
assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
}
#[test]
fn error_classifiers_recognise_markers() {
let conflict = Error::Vcs(processkit::Error::Exit {
program: "git".into(),
code: 1,
stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
stderr: String::new(),
});
assert!(conflict.is_merge_conflict());
assert!(!conflict.is_nothing_to_commit());
assert!(!Error::NotARepository("/x".into()).is_merge_conflict());
}
}
#[doc = include_str!("../docs/core.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {
#[doc = include_str!("../docs/cookbook.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod cookbook {}
#[doc = include_str!("../docs/process-model.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod process_model {}
#[doc = include_str!("../docs/positioning.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod positioning {}
#[doc = include_str!("../docs/stability.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod stability {}
}