diff --git a/src/config.rs b/src/config.rs index da99333..3f5513a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,7 @@ pub struct Server { #[serde(deny_unknown_fields)] pub struct Project { pub workdir: PathBuf, + pub branch: Option, pub server: Option, pub host: Option, pub user: Option, @@ -97,6 +98,11 @@ impl Config { if host.trim().is_empty() { bail!("project '{name}': host must not be empty"); } + if let Some(branch) = &mut project.branch + && branch.trim().is_empty() + { + bail!("project '{name}': branch must not be empty or whitespace"); + } for (phase, cmds) in [ ("local", &mut project.local), ("remote", &mut project.remote), @@ -269,6 +275,51 @@ commands = ["pm2 restart web"] assert!(toml::from_str::("this is [not toml").is_err()); } + #[test] + fn empty_branch_is_rejected() { + let s = r#" +[projects.web] +workdir = "." +host = "vps" +branch = "" +"#; + let err = parse(s).unwrap_err(); + assert!(err.to_string().contains("branch"), "{err:#}"); + } + + #[test] + fn whitespace_branch_is_rejected() { + let s = r#" +[projects.web] +workdir = "." +host = "vps" +branch = " " +"#; + let err = parse(s).unwrap_err(); + assert!(err.to_string().contains("branch"), "{err:#}"); + } + + #[test] + fn non_empty_branch_is_accepted() { + let s = r#" +[projects.web] +workdir = "." +host = "vps" +branch = "staging" +"#; + let cfg = parse(s).unwrap(); + assert_eq!( + cfg.projects.get("web").unwrap().branch.as_deref(), + Some("staging") + ); + } + + #[test] + fn missing_branch_defaults_to_none() { + let cfg = parse(VALID).unwrap(); + assert_eq!(cfg.projects.get("web").unwrap().branch, None); + } + #[test] fn unknown_field_is_rejected() { let s = r#" diff --git a/src/deploy.rs b/src/deploy.rs index 56f604d..923173d 100644 --- a/src/deploy.rs +++ b/src/deploy.rs @@ -1,12 +1,16 @@ use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Result, bail}; use colored::Colorize; use crate::config::Project; -use crate::{local, ssh, sync}; +use crate::{git, local, ssh, sync}; pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> { + if let Some(expected) = &project.branch { + verify_branch(name, project, expected)?; + } + println!("deploying '{name}'"); let local_cmds = project @@ -94,6 +98,29 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> { Ok(()) } +fn verify_branch(name: &str, project: &Project, expected: &str) -> Result<()> { + let expected = expected.trim(); + let current = git::current_branch(&project.workdir)?; + match current.as_deref() { + None => bail!( + "project '{name}' has branch '{expected}' set, but {} is not a git repository", + crate::config::display(&project.workdir) + ), + Some("HEAD") => bail!( + "project '{name}' is in detached HEAD state, expected branch '{expected}'; \ + return to a working branch with `git switch -c {expected}`" + ), + Some(actual) if actual != expected => bail!( + "project '{name}' is on branch '{actual}', expected '{expected}'; \ + switch branches with `git switch {expected}`" + ), + _ => { + println!("[Git] on branch '{}' ✓", expected.green()); + Ok(()) + } + } +} + fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) { println!("dry-run plan:"); for cmd in local_cmds { @@ -154,3 +181,118 @@ fn fmt_dur(d: Duration) -> String { format!("{:.2}s", d.as_secs_f64()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Project; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + fn tmpdir(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xboct-deploy-dep-{}-{tag}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(dir) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed in {}", dir.display()); + } + + fn git_repo(tag: &str) -> PathBuf { + let dir = tmpdir(tag); + git(&dir, &["init", "-b", "staging"]); + fs::write(dir.join("f.txt"), "hi").unwrap(); + git(&dir, &["add", "."]); + git( + &dir, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "init", + ], + ); + dir + } + + fn project_with_branch(workdir: PathBuf, branch: Option) -> Project { + Project { + workdir, + branch, + server: None, + host: Some("vps".to_string()), + user: None, + port: None, + key_path: None, + env: Default::default(), + local: None, + sync: Vec::new(), + remote: None, + } + } + + #[test] + fn no_branch_deploys_without_check() { + let dir = tmpdir("nobranch"); + let project = project_with_branch(dir.clone(), None); + deploy("p", &project, false).unwrap(); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn matching_branch_continues() { + let dir = git_repo("match"); + let project = project_with_branch(dir.clone(), Some("staging".to_string())); + deploy("p", &project, false).unwrap(); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn mismatched_branch_aborts() { + let dir = git_repo("mismatch"); + let project = project_with_branch(dir.clone(), Some("main".to_string())); + let err = deploy("p", &project, false).unwrap_err(); + assert!(err.to_string().contains("on branch 'staging'"), "{err:#}"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn not_a_git_repo_aborts() { + let dir = tmpdir("norepo"); + let project = project_with_branch(dir.clone(), Some("staging".to_string())); + let err = deploy("p", &project, false).unwrap_err(); + assert!(err.to_string().contains("not a git repository"), "{err:#}"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn detached_head_aborts() { + let dir = git_repo("detached"); + git(&dir, &["switch", "--detach"]); + let project = project_with_branch(dir.clone(), Some("staging".to_string())); + let err = deploy("p", &project, false).unwrap_err(); + assert!(err.to_string().contains("detached HEAD"), "{err:#}"); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn dry_run_validates_branch() { + let dir = git_repo("drymatch"); + let project = project_with_branch(dir.clone(), Some("main".to_string())); + let err = deploy("p", &project, true).unwrap_err(); + assert!(err.to_string().contains("on branch 'staging'"), "{err:#}"); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..0effaec --- /dev/null +++ b/src/git.rs @@ -0,0 +1,114 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +pub fn current_branch(workdir: &Path) -> Result> { + let out = match Command::new("git") + .current_dir(workdir) + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + { + Ok(out) => out, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + bail!("`git` not found in PATH — install git to use the 'branch' check") + } + Err(err) => return Err(err).context("failed to spawn `git`"), + }; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let fail = stderr.trim(); + if fail.contains("not a git repository") + || fail.contains("not inside any git repository") + || fail.contains("does not appear to be a git repository") + { + return Ok(None); + } + bail!("`git rev-parse` failed: {}", fail); + } + + let name = String::from_utf8(out.stdout) + .context("git returned non-UTF-8 output")? + .trim() + .to_string(); + if name.is_empty() { + return Ok(None); + } + Ok(Some(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn tmpdir(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xboct-deploy-git-{}-{tag}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn git(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(dir) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed in {}", dir.display()); + } + + fn git_repo(tag: &str) -> PathBuf { + let dir = tmpdir(tag); + git(&dir, &["init", "-b", "test"]); + fs::write(dir.join("f.txt"), "hi").unwrap(); + git(&dir, &["add", "."]); + git( + &dir, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-m", + "init", + ], + ); + dir + } + + #[test] + fn returns_branch_in_repo() { + let dir = git_repo("branch"); + assert_eq!(current_branch(&dir).unwrap().as_deref(), Some("test")); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn returns_head_when_detached() { + let dir = git_repo("detached"); + git(&dir, &["switch", "--detach"]); + assert_eq!(current_branch(&dir).unwrap().as_deref(), Some("HEAD")); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn returns_none_outside_repo() { + let dir = tmpdir("norepo"); + assert_eq!(current_branch(&dir).unwrap(), None); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn works_from_subdirectory() { + let dir = git_repo("subdir"); + let sub = dir.join("inner"); + fs::create_dir_all(&sub).unwrap(); + assert_eq!(current_branch(&sub).unwrap().as_deref(), Some("test")); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/src/main.rs b/src/main.rs index ed4a7c4..a5d40d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod cli; mod config; mod deploy; +mod git; mod local; mod ssh; mod sync;