use camino::Utf8PathBuf;
use serde::Serialize;
use std::path::Path;
use void_core::{cid, refs};
use crate::context::{find_void_dir, resolve_ref, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug)]
pub struct BranchArgs {
pub name: Option<String>,
pub target: Option<String>,
pub delete: bool,
pub force: bool,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HeadRefOutput {
pub kind: String,
pub value: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchOutput {
pub action: String,
pub current: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub head: Option<HeadRefOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branches: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
pub fn run(cwd: &Path, args: BranchArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("branch", opts, |ctx| {
let void_dir = find_void_dir(cwd)?;
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir.clone())
.map_err(|e| CliError::internal(format!("invalid void_dir path: {}", e)))?;
let current_branch = get_current_branch(&void_dir_utf8)?;
let head_ref = get_head_ref(&void_dir_utf8)?;
if args.delete {
let name = args
.name
.ok_or_else(|| CliError::invalid_args("branch name required for delete"))?;
delete_branch_cmd(ctx, &void_dir_utf8, &name, current_branch.as_deref())
} else if let Some(name) = args.name {
create_branch_cmd(
ctx,
&void_dir,
&void_dir_utf8,
&name,
args.target,
args.force,
current_branch,
)
} else {
list_branches_cmd(ctx, &void_dir_utf8, current_branch, head_ref)
}
})
}
fn get_current_branch(void_dir: &Utf8PathBuf) -> Result<Option<String>, CliError> {
match refs::read_head(void_dir).map_err(void_err_to_cli)? {
Some(refs::HeadRef::Symbolic(branch)) => Ok(Some(branch)),
_ => Ok(None),
}
}
fn get_head_ref(void_dir: &Utf8PathBuf) -> Result<Option<HeadRefOutput>, CliError> {
match refs::read_head(void_dir).map_err(void_err_to_cli)? {
Some(refs::HeadRef::Symbolic(branch)) => Ok(Some(HeadRefOutput {
kind: "symbolic".to_string(),
value: branch,
})),
Some(refs::HeadRef::Detached(commit_cid)) => {
let cid_str = cid::from_bytes(commit_cid.as_bytes())
.map(|c| c.to_string())
.map_err(|e| CliError::internal(format!("invalid CID: {}", e)))?;
Ok(Some(HeadRefOutput {
kind: "detached".to_string(),
value: cid_str,
}))
}
None => Ok(None),
}
}
fn list_branches_cmd(
ctx: &mut crate::output::CommandContext,
void_dir: &Utf8PathBuf,
current_branch: Option<String>,
head_ref: Option<HeadRefOutput>,
) -> Result<BranchOutput, CliError> {
ctx.progress("Listing branches...");
let branches = refs::list_branches(void_dir).map_err(void_err_to_cli)?;
if !ctx.use_json() {
if branches.is_empty() {
ctx.info("No branches found.");
} else {
for branch in &branches {
if current_branch.as_ref() == Some(branch) {
ctx.info(format!("* {}", branch));
} else {
ctx.info(format!(" {}", branch));
}
}
}
}
Ok(BranchOutput {
action: "list".to_string(),
current: current_branch,
head: head_ref,
branches: Some(branches),
name: None,
deleted: None,
target: None,
})
}
fn create_branch_cmd(
ctx: &mut crate::output::CommandContext,
void_dir: &std::path::PathBuf,
void_dir_utf8: &Utf8PathBuf,
name: &str,
target: Option<String>,
force: bool,
current_branch: Option<String>,
) -> Result<BranchOutput, CliError> {
ctx.progress(format!("Creating branch '{}'...", name));
if !force
&& refs::read_branch(void_dir_utf8, name)
.map_err(void_err_to_cli)?
.is_some()
{
return Err(CliError::conflict(format!(
"branch '{}' already exists",
name
)));
}
let target_ref = target.as_deref().unwrap_or("HEAD");
let target_cid_bytes = resolve_ref(void_dir, target_ref)?;
let target_cid_str = cid::from_bytes(target_cid_bytes.as_bytes())
.map(|c| c.to_string())
.map_err(|e| CliError::internal(format!("invalid CID: {}", e)))?;
refs::write_branch(void_dir_utf8, name, &target_cid_bytes).map_err(void_err_to_cli)?;
if !ctx.use_json() {
let short_cid = if target_cid_str.len() > 12 {
&target_cid_str[..12]
} else {
&target_cid_str
};
ctx.info(format!("Created branch '{}' at {}...", name, short_cid));
}
Ok(BranchOutput {
action: "create".to_string(),
current: current_branch,
head: None,
branches: None,
name: Some(name.to_string()),
deleted: None,
target: Some(target_cid_str),
})
}
fn delete_branch_cmd(
ctx: &mut crate::output::CommandContext,
void_dir: &Utf8PathBuf,
name: &str,
current_branch: Option<&str>,
) -> Result<BranchOutput, CliError> {
ctx.progress(format!("Deleting branch '{}'...", name));
if current_branch == Some(name) {
return Err(CliError::conflict(format!(
"cannot delete branch '{}' which is currently checked out",
name
)));
}
if refs::read_branch(void_dir, name)
.map_err(void_err_to_cli)?
.is_none()
{
return Err(CliError::not_found(format!("branch '{}' not found", name)));
}
refs::delete_branch(void_dir, name).map_err(void_err_to_cli)?;
if !ctx.use_json() {
ctx.info(format!("Deleted branch '{}'", name));
}
Ok(BranchOutput {
action: "delete".to_string(),
current: current_branch.map(|s| s.to_string()),
head: None,
branches: None,
name: None,
deleted: Some(name.to_string()),
target: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::CliOptions;
use std::fs;
use tempfile::tempdir;
use void_core::crypto;
fn default_opts() -> CliOptions {
CliOptions {
human: true,
..Default::default()
}
}
fn setup_test_repo() -> (tempfile::TempDir, std::path::PathBuf, tempfile::TempDir, crate::context::VoidHomeGuard) {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir_all(void_dir.join("objects")).unwrap();
fs::create_dir_all(void_dir.join("refs/heads")).unwrap();
let key = crypto::generate_key();
let home = tempdir().unwrap();
let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());
let repo_secret = hex::encode(crypto::generate_key());
fs::write(
void_dir.join("config.json"),
format!(r#"{{"repoSecret": "{}"}}"#, repo_secret),
)
.unwrap();
let cid_obj = cid::create(b"test commit");
let cid_str = cid_obj.to_string();
fs::write(void_dir.join("refs/heads/trunk"), format!("{}\n", cid_str)).unwrap();
fs::write(void_dir.join("HEAD"), "ref: refs/heads/trunk\n").unwrap();
(dir, void_dir, home, guard)
}
#[test]
fn test_list_branches_empty() {
let dir = tempdir().unwrap();
let void_dir = dir.path().join(".void");
fs::create_dir_all(void_dir.join("refs/heads")).unwrap();
let key = crypto::generate_key();
let home = tempdir().unwrap();
let _guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());
fs::write(void_dir.join("config.json"), "{}").unwrap();
let args = BranchArgs {
name: None,
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_list_branches_with_branches() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let cid_obj = cid::create(b"test");
let cid_str = cid_obj.to_string();
fs::write(
void_dir.join("refs/heads/develop"),
format!("{}\n", cid_str),
)
.unwrap();
let args = BranchArgs {
name: None,
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_create_branch() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = BranchArgs {
name: Some("feature/test".to_string()),
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_create_branch_already_exists() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = BranchArgs {
name: Some("trunk".to_string()),
target: None,
delete: false,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_create_branch_force_overwrites() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = BranchArgs {
name: Some("trunk".to_string()),
target: None,
delete: false,
force: true,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_delete_branch() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let cid_obj = cid::create(b"test");
let cid_str = cid_obj.to_string();
fs::write(
void_dir.join("refs/heads/to-delete"),
format!("{}\n", cid_str),
)
.unwrap();
let args = BranchArgs {
name: Some("to-delete".to_string()),
target: None,
delete: true,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
assert!(!void_dir.join("refs/heads/to-delete").exists());
}
#[test]
fn test_delete_current_branch_fails() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = BranchArgs {
name: Some("trunk".to_string()),
target: None,
delete: true,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_delete_nonexistent_branch() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = BranchArgs {
name: Some("nonexistent".to_string()),
target: None,
delete: true,
force: false,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_branch_output_serialization() {
let output = BranchOutput {
action: "list".to_string(),
current: Some("trunk".to_string()),
head: Some(HeadRefOutput {
kind: "symbolic".to_string(),
value: "trunk".to_string(),
}),
branches: Some(vec!["develop".to_string(), "trunk".to_string()]),
name: None,
deleted: None,
target: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"list\""));
assert!(json.contains("\"current\":\"trunk\""));
assert!(json.contains("\"branches\""));
assert!(json.contains("\"head\""));
assert!(json.contains("\"kind\":\"symbolic\""));
assert!(!json.contains("\"name\""));
assert!(!json.contains("\"target\""));
assert!(!json.contains("\"deleted\""));
}
#[test]
fn test_create_output_serialization() {
let output = BranchOutput {
action: "create".to_string(),
current: Some("trunk".to_string()),
head: None,
branches: None,
name: Some("feature".to_string()),
deleted: None,
target: Some("bafytest123".to_string()),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"create\""));
assert!(json.contains("\"name\":\"feature\""));
assert!(json.contains("\"target\":\"bafytest123\""));
assert!(!json.contains("\"branches\""));
assert!(!json.contains("\"head\""));
assert!(!json.contains("\"deleted\""));
}
#[test]
fn test_delete_output_serialization() {
let output = BranchOutput {
action: "delete".to_string(),
current: Some("trunk".to_string()),
head: None,
branches: None,
name: None,
deleted: Some("feature".to_string()),
target: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"delete\""));
assert!(json.contains("\"deleted\":\"feature\""));
assert!(!json.contains("\"name\""));
assert!(!json.contains("\"target\""));
assert!(!json.contains("\"branches\""));
assert!(!json.contains("\"head\""));
}
}