feat: add branch - config and workflow

This commit is contained in:
2026-09-03 08:33:45 +05:00
parent 973d348628
commit 6ad1720fcb
4 changed files with 310 additions and 2 deletions
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;