use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::git::{BranchInfo, ChangeEntry, CommitInfo, CommitMeta, DiffLine, FileStatus, GraphRow};
#[cfg(feature = "git")]
pub mod jj;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Caps {
pub write: bool,
}
pub trait Vcs: Send + Sync {
fn caps(&self) -> Caps;
fn workdir(&self, root: &Path) -> Option<PathBuf>;
fn statuses(&self, root: &Path) -> HashMap<PathBuf, FileStatus>;
fn ignored(&self, root: &Path) -> HashSet<PathBuf>;
fn branch(&self, root: &Path) -> Option<String>;
fn worktree_origin(&self, root: &Path) -> Option<String>;
fn file_diff(&self, root: &Path, file: &Path) -> Vec<DiffLine>;
fn changed_files(&self, root: &Path) -> Vec<ChangeEntry>;
fn log(&self, root: &Path, max: usize) -> Vec<CommitInfo>;
fn graph(
&self,
all: bool,
root: &Path,
base: Option<&str>,
lang: crate::i18n::Lang,
refs: Option<&[String]>,
) -> Vec<GraphRow>;
fn refs(&self, root: &Path) -> Vec<BranchInfo>;
fn ref_tip(&self, root: &Path, name: &str) -> Option<String>;
fn commit_meta(&self, root: &Path, id: &str) -> Option<CommitMeta>;
fn commit_diff(&self, root: &Path, id: &str) -> Vec<DiffLine>;
}
pub struct Git;
impl Vcs for Git {
fn caps(&self) -> Caps {
Caps { write: true }
}
fn workdir(&self, root: &Path) -> Option<PathBuf> {
crate::git::workdir(root)
}
fn statuses(&self, root: &Path) -> HashMap<PathBuf, FileStatus> {
crate::git::statuses(root)
}
fn ignored(&self, root: &Path) -> HashSet<PathBuf> {
crate::git::ignored(root)
}
fn branch(&self, root: &Path) -> Option<String> {
crate::git::branch(root)
}
fn worktree_origin(&self, root: &Path) -> Option<String> {
crate::git::worktree_origin(root)
}
fn file_diff(&self, root: &Path, file: &Path) -> Vec<DiffLine> {
crate::git::file_diff(root, file)
}
fn changed_files(&self, root: &Path) -> Vec<ChangeEntry> {
crate::git::changed_files(root)
}
fn log(&self, root: &Path, max: usize) -> Vec<CommitInfo> {
crate::git::log(root, max)
}
fn graph(
&self,
_all: bool,
root: &Path,
base: Option<&str>,
lang: crate::i18n::Lang,
refs: Option<&[String]>,
) -> Vec<GraphRow> {
crate::git::graph_with_base(root, base, lang, refs)
}
fn refs(&self, root: &Path) -> Vec<BranchInfo> {
crate::git::branches(root)
}
fn ref_tip(&self, root: &Path, name: &str) -> Option<String> {
crate::git::branch_tip(root, name)
}
fn commit_meta(&self, root: &Path, id: &str) -> Option<CommitMeta> {
crate::git::commit_meta(root, id)
}
fn commit_diff(&self, root: &Path, id: &str) -> Vec<DiffLine> {
crate::git::commit_diff(root, id)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preference {
Auto,
Git,
Jj,
}
impl Preference {
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"git" => Self::Git,
"jj" => Self::Jj,
_ => Self::Auto,
}
}
}
static PREFERENCE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
pub fn set_preference(p: Preference) {
let v = match p {
Preference::Auto => 0,
Preference::Git => 1,
Preference::Jj => 2,
};
PREFERENCE.store(v, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(all(test, feature = "git"))]
thread_local! {
static PREFERENCE_OVERRIDE: std::cell::Cell<Option<Preference>> =
const { std::cell::Cell::new(None) };
}
#[cfg(all(test, feature = "git"))]
pub fn set_preference_for_test(p: Option<Preference>) {
PREFERENCE_OVERRIDE.with(|c| c.set(p));
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
fn preference() -> Preference {
#[cfg(all(test, feature = "git"))]
if let Some(p) = PREFERENCE_OVERRIDE.with(|c| c.get()) {
return p;
}
match PREFERENCE.load(std::sync::atomic::Ordering::Relaxed) {
1 => Preference::Git,
2 => Preference::Jj,
_ => Preference::Auto,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VcsKind {
Git,
#[cfg(feature = "git")]
Jj,
}
#[cfg(feature = "git")]
pub struct Jj;
#[cfg(feature = "git")]
impl Vcs for Jj {
fn caps(&self) -> Caps {
Caps { write: false }
}
fn workdir(&self, root: &Path) -> Option<PathBuf> {
jj::workspace_root(root)
}
fn statuses(&self, root: &Path) -> HashMap<PathBuf, FileStatus> {
jj::statuses(root)
}
fn ignored(&self, root: &Path) -> HashSet<PathBuf> {
jj::ignored(root)
}
fn branch(&self, root: &Path) -> Option<String> {
jj::branch(root)
}
fn worktree_origin(&self, _root: &Path) -> Option<String> {
None
}
fn file_diff(&self, root: &Path, file: &Path) -> Vec<DiffLine> {
jj::file_diff(root, file)
}
fn changed_files(&self, root: &Path) -> Vec<ChangeEntry> {
jj::changed_files(root)
}
fn log(&self, root: &Path, max: usize) -> Vec<CommitInfo> {
jj::log(root, max)
}
fn graph(
&self,
all: bool,
root: &Path,
_base: Option<&str>,
_lang: crate::i18n::Lang,
_refs: Option<&[String]>,
) -> Vec<GraphRow> {
jj::graph(root, all.then_some("all()"), 400)
}
fn refs(&self, root: &Path) -> Vec<BranchInfo> {
jj::bookmarks(root)
}
fn ref_tip(&self, root: &Path, name: &str) -> Option<String> {
jj::bookmark_tip(root, name)
}
fn commit_meta(&self, root: &Path, id: &str) -> Option<CommitMeta> {
jj::commit_meta(root, id)
}
fn commit_diff(&self, root: &Path, id: &str) -> Vec<DiffLine> {
jj::commit_diff(root, id)
}
}
#[cfg(feature = "git")]
pub(crate) fn detect_with(pref: Preference, root: &Path, jj_available: bool) -> VcsKind {
if pref == Preference::Git {
return VcsKind::Git;
}
let git_answers = pref == Preference::Auto && crate::git::workdir(root).is_some();
if !git_answers && jj::workspace_root(root).is_some() && jj_available {
return VcsKind::Jj;
}
VcsKind::Git
}
pub fn detect(root: &Path) -> VcsKind {
#[cfg(feature = "git")]
{
detect_with(preference(), root, jj::available())
}
#[cfg(not(feature = "git"))]
{
let _ = root;
VcsKind::Git
}
}
pub fn backend_for(root: &Path) -> &'static dyn Vcs {
match detect(root) {
VcsKind::Git => &Git,
#[cfg(feature = "git")]
VcsKind::Jj => &Jj,
}
}
pub fn workdir(root: &Path) -> Option<PathBuf> {
backend_for(root).workdir(root)
}
pub fn statuses(root: &Path) -> HashMap<PathBuf, FileStatus> {
backend_for(root).statuses(root)
}
pub fn ignored(root: &Path) -> HashSet<PathBuf> {
backend_for(root).ignored(root)
}
pub fn branch(root: &Path) -> Option<String> {
backend_for(root).branch(root)
}
pub fn worktree_origin(root: &Path) -> Option<String> {
backend_for(root).worktree_origin(root)
}
pub fn file_diff(root: &Path, file: &Path) -> Vec<DiffLine> {
backend_for(root).file_diff(root, file)
}
pub fn log(root: &Path, max: usize) -> Vec<CommitInfo> {
backend_for(root).log(root, max)
}
pub fn graph(
all: bool,
root: &Path,
base: Option<&str>,
lang: crate::i18n::Lang,
refs: Option<&[String]>,
) -> Vec<GraphRow> {
backend_for(root).graph(all, root, base, lang, refs)
}
pub fn refs(root: &Path) -> Vec<BranchInfo> {
backend_for(root).refs(root)
}
pub fn ref_tip(root: &Path, name: &str) -> Option<String> {
backend_for(root).ref_tip(root, name)
}
pub fn commit_meta(root: &Path, id: &str) -> Option<CommitMeta> {
backend_for(root).commit_meta(root, id)
}
pub fn commit_diff(root: &Path, id: &str) -> Vec<DiffLine> {
backend_for(root).commit_diff(root, id)
}
pub fn caps(root: &Path) -> Caps {
backend_for(root).caps()
}
pub fn changed_files(root: &Path) -> Vec<ChangeEntry> {
backend_for(root).changed_files(root)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_agrees_with_the_free_functions() {
for root in [
std::path::Path::new(env!("CARGO_MANIFEST_DIR")),
std::path::Path::new("/"),
] {
let b = backend_for(root);
assert_eq!(
b.workdir(root),
crate::git::workdir(root),
"workdir {root:?}"
);
assert_eq!(
b.statuses(root).len(),
crate::git::statuses(root).len(),
"statuses {root:?}"
);
assert_eq!(b.branch(root), crate::git::branch(root), "branch {root:?}");
assert_eq!(
b.worktree_origin(root),
crate::git::worktree_origin(root),
"worktree_origin {root:?}"
);
}
}
#[test]
fn backend_can_cross_a_thread() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let handle = std::thread::spawn(move || backend_for(&root).workdir(&root));
assert!(handle.join().is_ok());
}
#[test]
fn preference_parse_table() {
let cases: &[(&str, Preference)] = &[
("git", Preference::Git),
("jj", Preference::Jj),
("auto", Preference::Auto),
("GIT", Preference::Git),
("Jj", Preference::Jj),
("AUTO", Preference::Auto),
(" git\n", Preference::Git),
("\tjj\t", Preference::Jj),
(" auto ", Preference::Auto),
("", Preference::Auto),
(" ", Preference::Auto),
("gti", Preference::Auto), ("mercurial", Preference::Auto), ("Git ", Preference::Git),
("\njj", Preference::Jj),
];
for (input, expected) in cases.iter().copied() {
assert_eq!(
Preference::parse(input),
expected,
"Preference::parse({input:?})"
);
}
}
#[cfg(feature = "git")]
fn detect_with_empty_dir(prefix: &str) -> PathBuf {
let dir = crate::test_support::unique_tmp(prefix);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(feature = "git")]
fn detect_with_git_only_dir() -> PathBuf {
let dir = detect_with_empty_dir("konoma_vcs_detect_git_only");
git2::Repository::init(&dir).unwrap();
dir
}
#[cfg(feature = "git")]
fn detect_with_jj_only_dir() -> PathBuf {
let dir = detect_with_empty_dir("konoma_vcs_detect_jj_only");
std::fs::create_dir_all(dir.join(".jj")).unwrap();
dir
}
#[cfg(feature = "git")]
fn detect_with_colocated_dir() -> PathBuf {
let dir = detect_with_empty_dir("konoma_vcs_detect_colocated");
git2::Repository::init(&dir).unwrap();
std::fs::create_dir_all(dir.join(".jj")).unwrap();
dir
}
#[cfg(feature = "git")]
fn detect_with_neither_dir() -> PathBuf {
detect_with_empty_dir("konoma_vcs_detect_neither")
}
#[cfg(feature = "git")]
#[test]
fn detect_with_covers_every_combination_of_pref_form_and_jj_availability() {
let git_only = detect_with_git_only_dir();
let jj_only = detect_with_jj_only_dir();
let colocated = detect_with_colocated_dir();
let neither = detect_with_neither_dir();
use Preference::{Auto, Git as G, Jj as J};
use VcsKind::{Git, Jj};
let cases: &[(Preference, &str, &Path, bool, VcsKind)] = &[
(G, "git_only", git_only.as_path(), true, Git),
(G, "git_only", git_only.as_path(), false, Git),
(G, "jj_only", jj_only.as_path(), true, Git),
(G, "jj_only", jj_only.as_path(), false, Git),
(G, "colocated", colocated.as_path(), true, Git),
(G, "colocated", colocated.as_path(), false, Git),
(G, "neither", neither.as_path(), true, Git),
(G, "neither", neither.as_path(), false, Git),
(Auto, "git_only", git_only.as_path(), true, Git),
(Auto, "git_only", git_only.as_path(), false, Git),
(Auto, "jj_only", jj_only.as_path(), true, Jj),
(Auto, "jj_only", jj_only.as_path(), false, Git), (Auto, "colocated", colocated.as_path(), true, Git), (Auto, "colocated", colocated.as_path(), false, Git),
(Auto, "neither", neither.as_path(), true, Git),
(Auto, "neither", neither.as_path(), false, Git),
(J, "git_only", git_only.as_path(), true, Git), (J, "git_only", git_only.as_path(), false, Git),
(J, "jj_only", jj_only.as_path(), true, Jj),
(J, "jj_only", jj_only.as_path(), false, Git), (J, "colocated", colocated.as_path(), true, Jj), (J, "colocated", colocated.as_path(), false, Git),
(J, "neither", neither.as_path(), true, Git),
(J, "neither", neither.as_path(), false, Git),
];
for (pref, form, root, jj_available, expected) in cases.iter().copied() {
assert_eq!(
detect_with(pref, root, jj_available),
expected,
"pref={pref:?} form={form} jj_available={jj_available}"
);
}
std::fs::remove_dir_all(&git_only).ok();
std::fs::remove_dir_all(&jj_only).ok();
std::fs::remove_dir_all(&colocated).ok();
std::fs::remove_dir_all(&neither).ok();
}
#[cfg(feature = "git")]
#[test]
fn detect_with_colocated_under_auto_stays_git() {
let dir = detect_with_colocated_dir();
assert_eq!(detect_with(Preference::Auto, &dir, true), VcsKind::Git);
assert_eq!(detect_with(Preference::Auto, &dir, false), VcsKind::Git);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn detect_with_colocated_under_explicit_jj_pref_becomes_jj() {
let dir = detect_with_colocated_dir();
assert_eq!(detect_with(Preference::Jj, &dir, true), VcsKind::Jj);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn detect_with_jj_only_under_explicit_git_pref_stays_git() {
let dir = detect_with_jj_only_dir();
assert_eq!(detect_with(Preference::Git, &dir, true), VcsKind::Git);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn detect_with_jj_only_under_auto_without_jj_binary_stays_git() {
let dir = detect_with_jj_only_dir();
assert_eq!(detect_with(Preference::Auto, &dir, false), VcsKind::Git);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn detect_with_colocated_falls_back_to_jj_when_git_integration_is_disabled() {
let dir = detect_with_colocated_dir();
crate::git::set_external_git_enabled(false);
assert_eq!(
detect_with(Preference::Auto, &dir, true),
VcsKind::Jj,
"with git integration off, crate::git::workdir reads None, so `auto` falls through to \
jj even though `.git` is right there"
);
crate::git::set_external_git_enabled(true);
assert_eq!(
detect_with(Preference::Auto, &dir, true),
VcsKind::Git,
"sanity: re-enabling restores the usual colocated-stays-git answer, proving the flag \
is not stuck off"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn detect_with_colocated_under_explicit_git_pref_ignores_the_disabled_flag() {
let dir = detect_with_colocated_dir();
crate::git::set_external_git_enabled(false);
assert_eq!(
detect_with(Preference::Git, &dir, true),
VcsKind::Git,
"an explicit git preference must not be swayed by [external] git"
);
crate::git::set_external_git_enabled(true);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn git_backend_caps_reports_write_true() {
assert!(
Git.caps().write,
"git can change the repository — unlike jj, see Jj::caps"
);
}
#[cfg(feature = "git")]
#[test]
fn backend_for_a_jj_only_directory_is_read_only() {
if !jj::available() {
eprintln!("skipping: no working `jj` binary on this machine");
return;
}
let dir = detect_with_jj_only_dir();
set_preference_for_test(Some(Preference::Auto));
assert_eq!(
detect(&dir),
VcsKind::Jj,
"sanity: detect must pick jj here"
);
let backend = backend_for(&dir);
assert!(
!backend.caps().write,
"a .jj-only directory's backend must be read-only"
);
set_preference_for_test(None);
std::fs::remove_dir_all(&dir).ok();
}
}