mod git;
pub(crate) mod jj;
mod trunk;
pub use self::trunk::PinnedBase;
use std::path::Path;
use anyhow::{bail, Result};
use vcs_runner::{jj_available, run_git_utf8};
pub use self::git::GitBackend;
pub use self::jj::JjBackend;
pub trait Vcs: Send + Sync {
fn detect_trunk(&self, project_dir: &Path) -> Result<String>;
fn resolve_base(&self, _project_dir: &Path, _rev: &str) -> Result<String> {
bail!("--base needs jj installed")
}
fn create_workspace(&self, project_dir: &Path, ws_dir: &Path, ws_id: &str, trunk: &str) -> Result<String>;
fn pre_copy_sync(&self, project_dir: &Path);
fn changed_files(&self, ws_id: &str, base: &str, project_dir: &Path, ws_dir: &Path) -> Vec<String>;
fn save_work(&self, ws_id: &str, base: &str, project_dir: &Path, ws_dir: &Path) -> Result<()>;
fn forget_workspace(&self, ws_id: &str, project_dir: &Path, ws_dir: &Path) -> Result<()>;
fn stranded_work(&self, _ws_id: &str, _base: &str, _project_dir: &Path, _ws_dir: &Path) -> Vec<String> {
Vec::new()
}
fn save_stranded(&self, _project_dir: &Path, _ws_id: &str, _commit_id: &str) -> Result<()> {
Ok(())
}
fn ignore_generated_file(&self, project_dir: &Path, ws_dir: &Path, relpath: &str);
}
pub fn detect(project_dir: &Path) -> Result<Box<dyn Vcs>> {
if project_dir.join(".jj").is_dir() {
return Ok(Box::new(JjBackend));
}
let has_git = project_dir.join(".git").is_dir();
if !has_git {
bail!("not a git or jj repository");
}
if jj_available() {
jj::init_jj(project_dir)?;
return Ok(Box::new(JjBackend));
}
Ok(Box::new(GitBackend))
}
pub(crate) fn path_str(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
pub(crate) fn append_git_exclude(project_dir: &Path, pattern: &str) -> Result<()> {
let git_dir = run_git_utf8(project_dir, &["rev-parse", "--absolute-git-dir"])
.map_err(|e| anyhow::anyhow!("could not locate .git directory: {e}"))?;
let exclude = Path::new(git_dir.trim()).join("info").join("exclude");
let existing = std::fs::read_to_string(&exclude).unwrap_or_default();
if existing.lines().any(|l| l.trim() == pattern) {
return Ok(());
}
if let Some(parent) = exclude.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(pattern);
content.push('\n');
std::fs::write(&exclude, content)?;
Ok(())
}
pub(crate) fn detect_git_remote(project_dir: &Path) -> String {
let remotes = run_git_utf8(project_dir, &["remote"]).unwrap_or_default();
let names: Vec<&str> = remotes.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
if names.contains(&"origin") {
return "origin".into();
}
names.first().map(|s| (*s).to_string()).unwrap_or_else(|| "origin".into())
}
#[cfg(test)]
mod tests {
use super::*;
struct Minimal;
impl Vcs for Minimal {
fn detect_trunk(&self, _: &Path) -> Result<String> {
Ok("main".into())
}
fn create_workspace(&self, _: &Path, _: &Path, _: &str, _: &str) -> Result<String> {
Ok("base".into())
}
fn pre_copy_sync(&self, _: &Path) {}
fn changed_files(&self, _: &str, _: &str, _: &Path, _: &Path) -> Vec<String> {
Vec::new()
}
fn save_work(&self, _: &str, _: &str, _: &Path, _: &Path) -> Result<()> {
Ok(())
}
fn forget_workspace(&self, _: &str, _: &Path, _: &Path) -> Result<()> {
Ok(())
}
fn ignore_generated_file(&self, _: &Path, _: &Path, _: &str) {}
}
#[test]
fn stranded_work_defaults_to_none() {
let dir = Path::new("/nonexistent");
assert!(Minimal.stranded_work("ws", "base", dir, dir).is_empty());
}
}