use camino::Utf8PathBuf;
use serde::Serialize;
use std::path::Path;
use void_core::cid::ToVoidCid;
use void_core::crypto::SecretKey;
use void_core::metadata::ShardMap;
use void_core::pipeline::{commit_workspace, CommitOptions, SealOptions};
use void_core::workspace::checkout::{checkout_tree, CheckoutOptions};
use void_core::workspace::stage::{stage_paths, StageOptions};
use void_core::{cid, refs, stash, store};
use void_core::VoidContext;
use crate::context::{
find_void_dir, load_signing_key, open_repo, signing_key_exists,
void_err_to_cli,
};
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug, Clone)]
pub enum StashAction {
Save { message: Option<String> },
List,
Pop { index: Option<u32> },
Drop { index: Option<u32> },
Clear,
}
#[derive(Debug)]
pub struct StashArgs {
pub action: StashAction,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StashEntryOutput {
pub index: u32,
pub commit_cid: String,
pub original_head: String,
pub message: Option<String>,
pub timestamp: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StashOutput {
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub entries: Option<Vec<StashEntryOutput>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_cid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_head: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<usize>,
}
fn format_relative_time(timestamp: u64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let diff = now.saturating_sub(timestamp);
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
let mins = diff / 60;
format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" })
} else if diff < 86400 {
let hours = diff / 3600;
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else if diff < 604800 {
let days = diff / 86400;
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
} else {
let weeks = diff / 604800;
format!("{} week{} ago", weeks, if weeks == 1 { "" } else { "s" })
}
}
pub fn run(cwd: &Path, args: StashArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("stash", opts, |ctx| {
let void_dir = find_void_dir(cwd)?;
let repo = open_repo(cwd)?;
let void_ctx = repo.context().clone();
let vault = repo.vault().clone();
let key = vault.stash_key().map_err(|e| void_err_to_cli(e.into()))?;
match args.action {
StashAction::List => run_list(ctx, &void_dir, key),
StashAction::Save { message } => run_save(ctx, cwd, &void_dir, &vault, key, message, &void_ctx),
StashAction::Pop { index } => run_pop(ctx, cwd, &void_dir, &vault, key, index.unwrap_or(0)),
StashAction::Drop { index } => run_drop(ctx, &void_dir, key, index.unwrap_or(0)),
StashAction::Clear => run_clear(ctx, &void_dir, key),
}
})
}
fn run_list(
ctx: &mut crate::output::CommandContext,
void_dir: &Path,
key: &SecretKey,
) -> Result<StashOutput, CliError> {
ctx.progress("Listing stash entries...");
let stack = stash::read_stash_stack(void_dir, key).map_err(void_err_to_cli)?;
let entries: Vec<StashEntryOutput> = stack
.entries
.iter()
.map(|e| {
let cid_str = cid::from_bytes(e.commit_cid.as_bytes())
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(e.commit_cid.as_bytes()));
let original_head_str = cid::from_bytes(e.original_head.as_bytes())
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(e.original_head.as_bytes()));
StashEntryOutput {
index: e.index,
commit_cid: cid_str,
original_head: original_head_str,
message: e.message.clone(),
timestamp: e.timestamp,
}
})
.collect();
if !ctx.use_json() {
if entries.is_empty() {
ctx.info("No stash entries.");
} else {
for entry in &entries {
let msg = entry.message.as_deref().unwrap_or("WIP on HEAD");
let time = format_relative_time(entry.timestamp);
ctx.info(format!("stash@{{{}}}: {} ({})", entry.index, msg, time));
}
}
}
Ok(StashOutput {
action: "list".to_string(),
entries: Some(entries),
index: None,
commit_cid: None,
original_head: None,
message: None,
count: None,
})
}
fn run_save(
ctx: &mut crate::output::CommandContext,
_cwd: &Path,
void_dir: &Path,
vault: &std::sync::Arc<void_core::crypto::KeyVault>,
key: &SecretKey,
message: Option<String>,
void_ctx: &VoidContext,
) -> Result<StashOutput, CliError> {
ctx.progress("Saving working directory to stash...");
let void_dir_utf8 = Utf8PathBuf::try_from(void_dir.to_path_buf())
.map_err(|e| CliError::internal(format!("invalid void_dir path: {}", e)))?;
let head_ref = refs::read_head(&void_dir_utf8)
.map_err(void_err_to_cli)?
.ok_or_else(|| CliError::not_found("HEAD is not set - nothing to stash"))?;
let head_cid = refs::resolve_head(&void_dir_utf8)
.map_err(void_err_to_cli)?
.ok_or_else(|| CliError::not_found("HEAD is not set - nothing to stash"))?;
let config =
void_core::config::load(void_dir).map_err(|e| CliError::internal(e.to_string()))?;
let repo_secret = match config.repo_secret {
Some(secret_hex) => {
let bytes = hex::decode(&secret_hex)
.map_err(|e| CliError::internal(format!("invalid repo_secret in config: {}", e)))?;
if bytes.len() != 32 {
return Err(CliError::internal(format!(
"repo_secret must be 32 bytes, got {}",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
void_core::crypto::RepoSecret::new(arr)
}
None => {
return Err(CliError::internal(
"Missing repoSecret in config. Repository may be corrupted.",
));
}
};
ctx.progress("Staging all changes...");
let stage_opts = StageOptions {
ctx: void_ctx.clone(),
patterns: vec![".".to_string()],
observer: None,
};
let _stage_result = stage_paths(stage_opts).map_err(void_err_to_cli)?;
let stash_message = message.clone().unwrap_or_else(|| "WIP on HEAD".to_string());
let signing_key = if signing_key_exists() {
load_signing_key().ok()
} else {
None
};
let mut stash_ctx = void_ctx.clone();
stash_ctx.repo.secret = repo_secret;
stash_ctx.crypto.signing_key = signing_key.map(std::sync::Arc::new);
let seal_opts = SealOptions {
ctx: stash_ctx,
shard_map: ShardMap::new(64),
content_key: None,
parent_content_key: None,
};
let commit_opts = CommitOptions {
seal: seal_opts,
message: format!("stash: {}", stash_message),
parent_cid: Some(head_cid.clone()),
allow_data_loss: true, foreign_parent: false,
};
ctx.progress("Creating stash commit...");
let result = commit_workspace(commit_opts).map_err(void_err_to_cli)?;
let mut stack = stash::read_stash_stack(void_dir, key).map_err(void_err_to_cli)?;
stack.push(result.commit_cid.clone(), head_cid.clone(), message.clone());
stash::write_stash_stack(void_dir, key, &stack).map_err(void_err_to_cli)?;
ctx.progress("Restoring clean working directory...");
let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects"))
.map_err(|e| CliError::internal(format!("invalid objects path: {}", e)))?;
let store = store::FsStore::new(objects_dir).map_err(void_err_to_cli)?;
let head_cid_obj = cid::from_bytes(head_cid.as_bytes())
.map_err(|e| CliError::internal(format!("invalid HEAD CID: {}", e)))?;
let workspace = void_dir
.parent()
.ok_or_else(|| CliError::internal("void_dir has no parent"))?;
let checkout_opts = CheckoutOptions {
paths: None,
force: true,
observer: None,
workspace_dir: None,
include_large: false,
};
checkout_tree(&store, &**vault, &head_cid_obj, workspace, &checkout_opts)
.map_err(void_err_to_cli)?;
match head_ref {
refs::HeadRef::Symbolic(branch) => {
refs::write_branch(&void_dir_utf8, &branch, &head_cid)
.map_err(void_err_to_cli)?;
}
refs::HeadRef::Detached(_) => {
refs::write_head(
&void_dir_utf8,
&refs::HeadRef::Detached(head_cid.clone()),
)
.map_err(void_err_to_cli)?;
}
}
if !ctx.use_json() {
let msg = message.as_deref().unwrap_or("WIP on HEAD");
ctx.info(format!("Saved working directory to stash@{{0}}: {}", msg));
}
Ok(StashOutput {
action: "save".to_string(),
entries: None,
index: Some(0),
commit_cid: Some(result.commit_cid.to_cid_string()),
original_head: None,
message,
count: None,
})
}
fn run_pop(
ctx: &mut crate::output::CommandContext,
_cwd: &Path,
void_dir: &Path,
vault: &std::sync::Arc<void_core::crypto::KeyVault>,
key: &SecretKey,
index: u32,
) -> Result<StashOutput, CliError> {
ctx.progress(format!("Applying stash@{{{}}}...", index));
let mut stack = stash::read_stash_stack(void_dir, key).map_err(void_err_to_cli)?;
let entry = stack
.get(index)
.cloned()
.ok_or_else(|| CliError::not_found(format!("stash@{{{}}} not found", index)))?;
let objects_dir = Utf8PathBuf::try_from(void_dir.join("objects"))
.map_err(|e| CliError::internal(format!("invalid objects path: {}", e)))?;
let store = store::FsStore::new(objects_dir).map_err(void_err_to_cli)?;
let stash_cid = cid::from_bytes(entry.commit_cid.as_bytes())
.map_err(|e| CliError::internal(format!("invalid stash commit CID: {}", e)))?;
let workspace = void_dir
.parent()
.ok_or_else(|| CliError::internal("void_dir has no parent"))?;
let checkout_opts = CheckoutOptions {
paths: None,
force: true,
observer: None,
workspace_dir: None,
include_large: false,
};
checkout_tree(&store, &**vault, &stash_cid, workspace, &checkout_opts).map_err(void_err_to_cli)?;
stack.remove(index);
stash::write_stash_stack(void_dir, key, &stack).map_err(void_err_to_cli)?;
let message = entry.message.clone();
let original_head_str = cid::from_bytes(entry.original_head.as_bytes())
.map(|c| c.to_string())
.unwrap_or_else(|_| hex::encode(entry.original_head.as_bytes()));
if !ctx.use_json() {
ctx.info(format!("Applied stash@{{{}}}", index));
if let Some(ref msg) = message {
ctx.info(format!(" {}", msg));
}
}
Ok(StashOutput {
action: "pop".to_string(),
entries: None,
index: Some(index),
commit_cid: None,
original_head: Some(original_head_str),
message,
count: None,
})
}
fn run_drop(
ctx: &mut crate::output::CommandContext,
void_dir: &Path,
key: &SecretKey,
index: u32,
) -> Result<StashOutput, CliError> {
ctx.progress(format!("Dropping stash@{{{}}}...", index));
let mut stack = stash::read_stash_stack(void_dir, key).map_err(void_err_to_cli)?;
let entry = stack
.get(index)
.cloned()
.ok_or_else(|| CliError::not_found(format!("stash@{{{}}} not found", index)))?;
let message = entry.message.clone();
stack.remove(index);
stash::write_stash_stack(void_dir, key, &stack).map_err(void_err_to_cli)?;
if !ctx.use_json() {
ctx.info(format!("Dropped stash@{{{}}}", index));
}
Ok(StashOutput {
action: "drop".to_string(),
entries: None,
index: Some(index),
commit_cid: None,
original_head: None,
message,
count: None,
})
}
fn run_clear(
ctx: &mut crate::output::CommandContext,
void_dir: &Path,
key: &SecretKey,
) -> Result<StashOutput, CliError> {
ctx.progress("Clearing stash...");
let stack = stash::read_stash_stack(void_dir, key).map_err(void_err_to_cli)?;
let count = stack.len();
stash::clear_stash(void_dir, key).map_err(void_err_to_cli)?;
if !ctx.use_json() {
if count == 0 {
ctx.info("Stash already empty.");
} else {
ctx.info(format!(
"Cleared {} stash {}",
count,
if count == 1 { "entry" } else { "entries" }
));
}
}
Ok(StashOutput {
action: "clear".to_string(),
entries: None,
index: None,
commit_cid: None,
original_head: None,
message: None,
count: Some(count),
})
}
#[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();
fs::write(dir.path().join("test.txt"), "hello world").unwrap();
(dir, void_dir, home, guard)
}
#[test]
fn test_stash_list_empty() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = StashArgs {
action: StashAction::List,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_stash_clear_empty() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = StashArgs {
action: StashAction::Clear,
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_ok());
}
#[test]
fn test_stash_drop_not_found() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = StashArgs {
action: StashAction::Drop { index: Some(5) },
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_stash_pop_not_found() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = StashArgs {
action: StashAction::Pop { index: Some(0) },
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_stash_save_no_head() {
let (dir, _void_dir, _home, _guard) = setup_test_repo();
let args = StashArgs {
action: StashAction::Save {
message: Some("test".to_string()),
},
};
let result = run(dir.path(), args, &default_opts());
assert!(result.is_err());
}
#[test]
fn test_format_relative_time() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(format_relative_time(now), "just now");
assert_eq!(format_relative_time(now - 120), "2 minutes ago");
assert_eq!(format_relative_time(now - 3600), "1 hour ago");
assert_eq!(format_relative_time(now - 7200), "2 hours ago");
assert_eq!(format_relative_time(now - 86400), "1 day ago");
assert_eq!(format_relative_time(now - 172800), "2 days ago");
assert_eq!(format_relative_time(now - 604800), "1 week ago");
}
#[test]
fn test_stash_output_serialization() {
let output = StashOutput {
action: "list".to_string(),
entries: Some(vec![StashEntryOutput {
index: 0,
commit_cid: "bafytest123".to_string(),
original_head: "bafyhead456".to_string(),
message: Some("WIP".to_string()),
timestamp: 1234567890,
}]),
index: None,
commit_cid: None,
original_head: None,
message: None,
count: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"list\""));
assert!(json.contains("\"entries\""));
assert!(json.contains("\"message\":\"WIP\""));
assert!(!json.contains("\"count\""));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("index").is_none());
}
#[test]
fn test_save_output_serialization() {
let output = StashOutput {
action: "save".to_string(),
entries: None,
index: Some(0),
commit_cid: Some("bafytest123".to_string()),
original_head: None,
message: Some("my stash".to_string()),
count: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"save\""));
assert!(json.contains("\"index\":0"));
assert!(json.contains("\"message\":\"my stash\""));
assert!(json.contains("\"commitCid\":\"bafytest123\""));
assert!(!json.contains("\"entries\""));
}
#[test]
fn test_clear_output_serialization() {
let output = StashOutput {
action: "clear".to_string(),
entries: None,
index: None,
commit_cid: None,
original_head: None,
message: None,
count: Some(3),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"action\":\"clear\""));
assert!(json.contains("\"count\":3"));
assert!(!json.contains("\"entries\""));
assert!(!json.contains("\"index\""));
}
}