use std::path::Path;
use std::sync::Arc;
use camino::Utf8PathBuf;
use serde::Serialize;
use void_core::crypto::{CommitCid, KeyVault};
use void_core::store::FsStore;
use void_core::workspace::checkout::{checkout_tree, CheckoutOptions, CheckoutStats};
use void_core::workspace::stage::{status_workspace, StatusOptions};
use void_core::{cid, refs};
use crate::context::{build_void_context, find_void_dir, resolve_ref, void_err_to_cli};
use crate::observer::ProgressObserver;
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug)]
pub struct SwitchArgs {
pub target: Option<String>,
pub create: bool,
pub detach: Option<String>,
pub force: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SwitchOutput {
pub head: HeadRefOutput,
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HeadRefOutput {
pub kind: String,
pub value: String,
}
pub fn run(cwd: &Path, args: SwitchArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("switch", 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 workspace = void_dir
.parent()
.ok_or_else(|| CliError::internal("void_dir has no parent"))?;
let void_ctx = build_void_context(cwd)?;
let vault = void_ctx.crypto.vault.clone();
if !args.force {
ctx.progress("Checking for uncommitted changes...");
check_clean_workspace_with_ctx(void_ctx)?;
}
if let Some(ref detach_cid) = args.detach {
ctx.progress("Checking out files...");
let target_cid_bytes = resolve_ref(&void_dir, detach_cid)?;
let _stats = checkout_commit(
&void_dir,
&*vault,
&target_cid_bytes,
workspace,
args.force,
ctx.use_json(),
)?;
refs::write_head(&void_dir_utf8, &refs::HeadRef::Detached(target_cid_bytes))
.map_err(void_err_to_cli)?;
if !ctx.use_json() {
ctx.info(format!(
"Note: switching to detached HEAD at '{}'",
detach_cid
));
ctx.info("");
ctx.info(
"You are in 'detached HEAD' state. You can look around, make experimental",
);
ctx.info(
"changes and commit them, and you can discard any commits you make in this",
);
ctx.info("state without impacting any branches by switching back to a branch.");
}
return Ok(SwitchOutput {
head: HeadRefOutput {
kind: "detached".to_string(),
value: detach_cid.clone(),
},
});
}
let target = args
.target
.as_ref()
.ok_or_else(|| CliError::invalid_args("Branch name is required"))?;
if args.create {
ctx.progress(format!("Creating branch '{}'...", target));
create_branch(&void_dir, &void_dir_utf8, target)?;
}
let (target_cid_bytes, _is_branch) = if args.create {
let cid_bytes = resolve_ref(&void_dir, target)?;
(cid_bytes, true)
} else {
let is_branch = refs::read_branch(&void_dir_utf8, target)
.map_err(void_err_to_cli)?
.is_some();
if !is_branch {
return Err(CliError::not_found(format!("Branch not found: {}", target)));
}
let cid_bytes = resolve_ref(&void_dir, target)?;
(cid_bytes, is_branch)
};
ctx.progress("Checking out files...");
let _stats = checkout_commit(
&void_dir,
&*vault,
&target_cid_bytes,
workspace,
args.force,
ctx.use_json(),
)?;
refs::write_head(&void_dir_utf8, &refs::HeadRef::Symbolic(target.clone()))
.map_err(void_err_to_cli)?;
if !ctx.use_json() {
if args.create {
ctx.info(format!("Switched to branch '{}' (newly created)", target));
} else {
ctx.info(format!("Switched to branch '{}'", target));
}
}
Ok(SwitchOutput {
head: HeadRefOutput {
kind: "symbolic".to_string(),
value: target.clone(),
},
})
})
}
#[allow(dead_code)] fn get_current_head_name(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)),
Some(refs::HeadRef::Detached(commit_cid)) => {
let cid_obj = cid::from_bytes(commit_cid.as_bytes())
.map_err(|e| CliError::internal(format!("invalid CID: {}", e)))?;
let cid_str = cid_obj.to_string();
let short = if cid_str.len() > 12 {
&cid_str[..12]
} else {
&cid_str
};
Ok(Some(format!("detached:{}", short)))
}
None => Ok(None),
}
}
fn check_clean_workspace_with_ctx(void_ctx: void_core::VoidContext) -> Result<(), CliError> {
let status_opts = StatusOptions {
ctx: void_ctx,
patterns: vec![],
observer: None,
};
let result = status_workspace(status_opts).map_err(void_err_to_cli)?;
let has_changes = !result.staged_added.is_empty()
|| !result.staged_modified.is_empty()
|| !result.staged_deleted.is_empty()
|| !result.unstaged_modified.is_empty()
|| !result.unstaged_deleted.is_empty();
if has_changes {
return Err(CliError::conflict(
"You have uncommitted changes. Commit or stash them before switching, or use --force to discard them.",
));
}
Ok(())
}
fn create_branch(void_dir: &Path, void_dir_utf8: &Utf8PathBuf, name: &str) -> Result<(), CliError> {
if 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 head_cid_bytes = resolve_ref(void_dir, "HEAD")?;
refs::write_branch(void_dir_utf8, name, &head_cid_bytes).map_err(void_err_to_cli)?;
Ok(())
}
fn checkout_commit(
void_dir: &Path,
vault: &KeyVault,
commit_cid: &CommitCid,
workspace: &Path,
force: bool,
use_json: bool,
) -> Result<CheckoutStats, CliError> {
let commit_cid =
cid::from_bytes(commit_cid.as_bytes()).map_err(|e| CliError::internal(e.to_string()))?;
let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects"))
.map_err(|e| CliError::internal(format!("invalid objects path: {}", e)))?;
let store = FsStore::new(objects_dir)
.map_err(|e| CliError::internal(format!("failed to open store: {}", e)))?;
let observer: Arc<ProgressObserver> = if use_json {
Arc::new(ProgressObserver::new_hidden())
} else {
Arc::new(ProgressObserver::new("Restoring files..."))
};
let checkout_opts = CheckoutOptions {
paths: None, force,
observer: Some(observer.clone()),
workspace_dir: None,
};
let stats = checkout_tree(&store, vault, &commit_cid, workspace, &checkout_opts)
.map_err(void_err_to_cli)?;
observer.finish();
Ok(stats)
}
#[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 = void_core::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_get_current_head_name_symbolic() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir).unwrap();
let name = get_current_head_name(&void_dir_utf8).unwrap();
assert_eq!(name, Some("trunk".to_string()));
drop(dir);
}
#[test]
fn test_get_current_head_name_detached() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir.clone()).unwrap();
let cid_obj = void_core::cid::create(b"test");
let cid_str = cid_obj.to_string();
fs::write(void_dir.join("HEAD"), format!("{}\n", cid_str)).unwrap();
let name = get_current_head_name(&void_dir_utf8).unwrap();
assert!(name.is_some());
assert!(name.unwrap().starts_with("detached:"));
drop(dir);
}
#[test]
fn test_switch_output_serialization_symbolic() {
let output = SwitchOutput {
head: HeadRefOutput {
kind: "symbolic".to_string(),
value: "feature".to_string(),
},
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"kind\":\"symbolic\""));
assert!(json.contains("\"value\":\"feature\""));
}
#[test]
fn test_switch_output_serialization_detached() {
let output = SwitchOutput {
head: HeadRefOutput {
kind: "detached".to_string(),
value: "bafkreiabc123".to_string(),
},
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"kind\":\"detached\""));
assert!(json.contains("\"value\":\"bafkreiabc123\""));
}
#[test]
fn test_create_branch_already_exists() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir.clone()).unwrap();
let result = create_branch(&void_dir, &void_dir_utf8, "trunk");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("already exists"));
drop(dir);
}
#[test]
fn test_create_branch_new() {
let (dir, void_dir, _home, _guard) = setup_test_repo();
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir.clone()).unwrap();
let result = create_branch(&void_dir, &void_dir_utf8, "new-branch");
assert!(result.is_ok());
assert!(void_dir.join("refs/heads/new-branch").exists());
drop(dir);
}
#[test]
fn test_switch_to_nonexistent_branch_fails() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = SwitchArgs {
target: Some("nonexistent".to_string()),
create: false,
detach: None,
force: true, };
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_switch_requires_target_without_detach() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = SwitchArgs {
target: None,
create: false,
detach: None,
force: true,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
}