use std::path::{Path, PathBuf};
use std::sync::Arc;
use processkit::{JobRunner, ProcessRunner};
use vcs_git::Git;
use vcs_jj::Jj;
mod dto;
mod error;
mod git_backend;
mod jj_backend;
pub use dto::{BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, WorktreeInfo};
pub use error::{Error, Result};
pub use vcs_git;
pub use vcs_jj;
#[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 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>> {
match &self.backend {
Backend::Git(g) => git_backend::trunk(g, &self.cwd).await,
Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await,
}
}
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 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 commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
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 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,
}
}
}
#[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 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 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_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"]
);
}
}