feat: add branch - config and workflow
This commit is contained in:
@@ -30,6 +30,7 @@ pub struct Server {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Project {
|
pub struct Project {
|
||||||
pub workdir: PathBuf,
|
pub workdir: PathBuf,
|
||||||
|
pub branch: Option<String>,
|
||||||
pub server: Option<String>,
|
pub server: Option<String>,
|
||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
pub user: Option<String>,
|
pub user: Option<String>,
|
||||||
@@ -97,6 +98,11 @@ impl Config {
|
|||||||
if host.trim().is_empty() {
|
if host.trim().is_empty() {
|
||||||
bail!("project '{name}': host must not be 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 [
|
for (phase, cmds) in [
|
||||||
("local", &mut project.local),
|
("local", &mut project.local),
|
||||||
("remote", &mut project.remote),
|
("remote", &mut project.remote),
|
||||||
@@ -269,6 +275,51 @@ commands = ["pm2 restart web"]
|
|||||||
assert!(toml::from_str::<Config>("this is [not toml").is_err());
|
assert!(toml::from_str::<Config>("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]
|
#[test]
|
||||||
fn unknown_field_is_rejected() {
|
fn unknown_field_is_rejected() {
|
||||||
let s = r#"
|
let s = r#"
|
||||||
|
|||||||
+144
-2
@@ -1,12 +1,16 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Result, bail};
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
|
|
||||||
use crate::config::Project;
|
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<()> {
|
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}'");
|
println!("deploying '{name}'");
|
||||||
|
|
||||||
let local_cmds = project
|
let local_cmds = project
|
||||||
@@ -94,6 +98,29 @@ pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> {
|
|||||||
Ok(())
|
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]) {
|
fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) {
|
||||||
println!("dry-run plan:");
|
println!("dry-run plan:");
|
||||||
for cmd in local_cmds {
|
for cmd in local_cmds {
|
||||||
@@ -154,3 +181,118 @@ fn fmt_dur(d: Duration) -> String {
|
|||||||
format!("{:.2}s", d.as_secs_f64())
|
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<String>) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
pub fn current_branch(workdir: &Path) -> Result<Option<String>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod deploy;
|
mod deploy;
|
||||||
|
mod git;
|
||||||
mod local;
|
mod local;
|
||||||
mod ssh;
|
mod ssh;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|||||||
Reference in New Issue
Block a user