use super::{Action, ActionContext, ActionFactory, ActionKind, ActionOutcome};
use crate::error::FrostxError;
pub const REGISTRY: &[(&str, ActionFactory)] = &[
("vcs.check_clean", |config, tag| {
Ok(Box::new(CheckClean {
skip_if_no_vcs: config.resolve_vcs(tag).skip_if_no_vcs,
}))
}),
("vcs.check_pushed", |config, tag| {
Ok(Box::new(CheckPushed {
skip_if_no_vcs: config.resolve_vcs(tag).skip_if_no_vcs,
}))
}),
("vcs.mark", |config, tag| {
Ok(Box::new(Mark {
skip_if_no_vcs: config.resolve_vcs(tag).skip_if_no_vcs,
}))
}),
];
trait VcsBackend: Send + Sync {
fn is_applicable(&self, path: &std::path::Path) -> bool;
fn check_clean(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError>;
fn check_pushed(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError>;
fn mark(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError>;
}
struct GitBackend;
struct JjBackend;
impl VcsBackend for GitBackend {
fn is_applicable(&self, path: &std::path::Path) -> bool {
path.join(".git").exists()
}
fn check_clean(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
super::git::CheckClean.run(ctx)
}
fn check_pushed(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
super::git::CheckPushed.run(ctx)
}
fn mark(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
super::git::Tag.run(ctx)
}
}
impl VcsBackend for JjBackend {
fn is_applicable(&self, path: &std::path::Path) -> bool {
path.join(".jj").exists()
}
fn check_clean(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
super::jj::CheckClean.run(ctx)
}
fn check_pushed(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
super::jj::CheckPushed.run(ctx)
}
fn mark(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
super::jj::Bookmark.run(ctx)
}
}
fn backends() -> Vec<Box<dyn VcsBackend>> {
vec![Box::new(JjBackend), Box::new(GitBackend)]
}
fn find_backend(path: &std::path::Path) -> Option<Box<dyn VcsBackend>> {
backends().into_iter().find(|b| b.is_applicable(path))
}
fn no_vcs_outcome(skip_if_no_vcs: bool) -> ActionOutcome {
if skip_if_no_vcs {
ActionOutcome::skipped("no VCS repository detected")
} else {
ActionOutcome::failed(
"no VCS repository detected (set [config.vcs] skip_if_no_vcs = true to skip instead)",
)
}
}
pub struct CheckClean {
skip_if_no_vcs: bool,
}
impl Action for CheckClean {
fn name(&self) -> &'static str {
"vcs.check_clean"
}
fn kind(&self) -> ActionKind {
ActionKind::Check
}
fn run(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
match find_backend(ctx.project_path) {
Some(b) => b.check_clean(ctx),
None => Ok(no_vcs_outcome(self.skip_if_no_vcs)),
}
}
}
pub struct CheckPushed {
skip_if_no_vcs: bool,
}
impl Action for CheckPushed {
fn name(&self) -> &'static str {
"vcs.check_pushed"
}
fn kind(&self) -> ActionKind {
ActionKind::Check
}
fn run(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
match find_backend(ctx.project_path) {
Some(b) => b.check_pushed(ctx),
None => Ok(no_vcs_outcome(self.skip_if_no_vcs)),
}
}
}
pub struct Mark {
skip_if_no_vcs: bool,
}
impl Action for Mark {
fn name(&self) -> &'static str {
"vcs.mark"
}
fn kind(&self) -> ActionKind {
ActionKind::Mutation
}
fn run(&self, ctx: &ActionContext<'_>) -> Result<ActionOutcome, FrostxError> {
match find_backend(ctx.project_path) {
Some(b) => b.mark(ctx),
None => Ok(no_vcs_outcome(self.skip_if_no_vcs)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::project::ActionConfig;
use std::collections::HashMap;
use std::process::Command;
use tempfile::tempdir;
use uuid::Uuid;
fn make_ctx<'a>(
path: &'a std::path::Path,
config: &'a crate::config::project::ProjectConfig,
) -> ActionContext<'a> {
ActionContext {
project_path: path,
config,
dry_run: false,
yes: true,
}
}
fn make_config() -> crate::config::project::ProjectConfig {
crate::config::project::ProjectConfig {
id: Uuid::new_v4(),
name: None,
description: None,
include: vec![],
template: std::collections::HashMap::new(),
groups: HashMap::new(),
config: ActionConfig::default(),
rules: vec![],
}
}
fn make_config_skip_if_no_vcs() -> crate::config::project::ProjectConfig {
use crate::config::project::VcsConfig;
let mut cfg = make_config();
cfg.config.vcs = Some(VcsConfig {
skip_if_no_vcs: true,
overrides: std::collections::HashMap::new(),
});
cfg
}
fn init_git(dir: &std::path::Path) {
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn check_clean_non_vcs_fails_by_default() {
let tmp = tempdir().unwrap();
let cfg = make_config();
let ctx = make_ctx(tmp.path(), &cfg);
let out = CheckClean {
skip_if_no_vcs: false,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::Failed);
}
#[test]
fn check_pushed_non_vcs_fails_by_default() {
let tmp = tempdir().unwrap();
let cfg = make_config();
let ctx = make_ctx(tmp.path(), &cfg);
let out = CheckPushed {
skip_if_no_vcs: false,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::Failed);
}
#[test]
fn mark_non_vcs_fails_by_default() {
let tmp = tempdir().unwrap();
let cfg = make_config();
let ctx = make_ctx(tmp.path(), &cfg);
let out = Mark {
skip_if_no_vcs: false,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::Failed);
}
#[test]
fn check_clean_non_vcs_skips_when_opted_in() {
let tmp = tempdir().unwrap();
let cfg = make_config_skip_if_no_vcs();
let ctx = make_ctx(tmp.path(), &cfg);
let out = CheckClean {
skip_if_no_vcs: true,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::Skipped);
}
#[test]
fn mark_non_vcs_skips_when_opted_in() {
let tmp = tempdir().unwrap();
let cfg = make_config_skip_if_no_vcs();
let ctx = make_ctx(tmp.path(), &cfg);
let out = Mark {
skip_if_no_vcs: true,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::Skipped);
}
#[test]
fn check_clean_delegates_to_git() {
let tmp = tempdir().unwrap();
init_git(tmp.path());
std::fs::write(tmp.path().join("README.md"), "hello").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(tmp.path())
.output()
.unwrap();
let cfg = make_config();
let ctx = make_ctx(tmp.path(), &cfg);
let out = CheckClean {
skip_if_no_vcs: false,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::Ok);
}
#[test]
fn mark_git_dry_run() {
let tmp = tempdir().unwrap();
init_git(tmp.path());
std::fs::write(tmp.path().join("f.txt"), "x").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(tmp.path())
.output()
.unwrap();
let cfg = make_config();
let mut ctx = make_ctx(tmp.path(), &cfg);
ctx.dry_run = true;
let out = Mark {
skip_if_no_vcs: false,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::DryRun);
assert!(
out.message.contains("tag"),
"expected git tag message, got: {}",
out.message
);
}
#[test]
fn mark_jj_dry_run() {
let tmp = tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".jj")).unwrap();
let cfg = make_config();
let mut ctx = make_ctx(tmp.path(), &cfg);
ctx.dry_run = true;
let out = Mark {
skip_if_no_vcs: false,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::DryRun);
assert!(
out.message.contains("bookmark"),
"expected jj bookmark message, got: {}",
out.message
);
}
#[test]
fn jj_preferred_over_git_when_both_present() {
let tmp = tempdir().unwrap();
std::fs::create_dir(tmp.path().join(".jj")).unwrap();
init_git(tmp.path());
let cfg = make_config();
let mut ctx = make_ctx(tmp.path(), &cfg);
ctx.dry_run = true;
let out = Mark {
skip_if_no_vcs: false,
}
.run(&ctx)
.unwrap();
assert_eq!(out.status, crate::pipeline::ActionStatus::DryRun);
assert!(
out.message.contains("bookmark"),
"expected jj backend, got: {}",
out.message
);
}
}