use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use void_core::cid;
use void_core::crypto::{CommitReader, KeyVault};
use void_core::support::ToVoidCid;
use void_core::metadata::{self, ShardMap};
use void_core::ops::import::{self, ImportOptions};
use void_core::pipeline::{commit_workspace, CommitOptions, SealOptions};
use void_core::refs;
use void_core::store::{FsStore, IpfsStore};
use void_core::support::events::VoidObserver;
use void_core::workspace::checkout::{restore_files, FileToRestore};
use crate::context::{find_void_dir, load_signing_key, open_repo, signing_key_exists, void_err_to_cli};
use crate::ipfs_utils::{format_bytes, make_observer, parse_backend, parse_content_key_required};
use crate::observer::ProgressObserver;
use crate::output::{run_command, CliError, CliOptions};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PullRequestOutput {
pub branch: String,
pub source_cid: String,
pub commit_cid: String,
pub files: usize,
pub original_message: String,
}
pub struct PullRequestArgs {
pub source: String,
pub name: String,
pub content_key: String,
pub backend: String,
pub kubo_url: String,
pub gateway_url: Option<String>,
pub timeout_ms: u64,
}
pub fn run(cwd: &Path, args: PullRequestArgs, opts: &CliOptions) -> Result<(), CliError> {
run_command("pull-request", opts, |ctx| {
let void_dir = find_void_dir(cwd)?;
let branch_name = if args.name.starts_with("pr/") {
args.name.clone()
} else {
format!("pr/{}", args.name)
};
let void_dir_utf8 = camino::Utf8Path::new(
void_dir.to_str().ok_or_else(|| CliError::internal("void_dir is not valid UTF-8"))?,
);
if refs::read_branch(void_dir_utf8, &branch_name).map_err(void_err_to_cli)?.is_some() {
return Err(CliError::conflict(format!(
"branch '{}' already exists — use a different name or delete it first",
branch_name
)));
}
let content_key = parse_content_key_required(&args.content_key)?;
let backend = parse_backend(&args.backend, &args.kubo_url, &args.gateway_url)?;
let remote = Arc::new(IpfsStore::new(backend, Duration::from_millis(args.timeout_ms)));
ctx.progress("Fetching published commit from IPFS...");
let shard_observer: Arc<ProgressObserver> =
Arc::new(make_observer(ctx.use_json(), "Fetching shards..."));
let shard_obs = shard_observer.clone();
let store = import::objects_store(&void_dir).map_err(void_err_to_cli)?;
let foreign = import::fetch_published_commit(ImportOptions {
store,
remote,
commit_cid: args.source.clone(),
content_key,
on_shard_progress: Some(Box::new(move |fetched, total| {
shard_obs.set_message(&format!("Fetched {}/{} shards", fetched, total));
})),
})
.map_err(void_err_to_cli)?;
shard_observer.finish();
let original_message = foreign.commit.message.clone();
ctx.progress("Extracting files...");
let temp_dir = tempfile::tempdir()
.map_err(|e| CliError::io_error(format!("failed to create temp dir: {e}")))?;
let temp_void_dir = temp_dir.path().join(".void");
std::fs::create_dir_all(temp_void_dir.join("objects"))
.map_err(|e| CliError::io_error(format!("failed to create temp void dir: {e}")))?;
let repo = open_repo(cwd)?;
let owner_ctx = repo.context().clone();
let store = import::objects_store(&void_dir).map_err(void_err_to_cli)?;
let checkout_observer: Arc<ProgressObserver> =
Arc::new(make_observer(ctx.use_json(), "Restoring files..."));
let files_extracted = extract_foreign_files(
&store,
&foreign.commit,
&foreign.reader,
&owner_ctx.crypto.vault,
temp_dir.path(),
&temp_void_dir,
&checkout_observer,
)?;
checkout_observer.finish();
ctx.progress("Creating PR commit...");
let source_cid_obj = cid::parse(&args.source).map_err(void_err_to_cli)?;
let source_cid_bytes = cid::to_bytes(&source_cid_obj);
let signing_key = if signing_key_exists() {
Some(Arc::new(load_signing_key()?))
} else {
None
};
let mut pr_ctx = owner_ctx.clone();
pr_ctx.paths.root = camino::Utf8PathBuf::try_from(temp_dir.path().to_path_buf())
.map_err(|e| CliError::internal(format!("invalid temp path: {e}")))?;
pr_ctx.crypto.signing_key = signing_key;
pr_ctx.paths.workspace_dir = pr_ctx.paths.root.clone();
let commit_message = format!(
"PR: {}\n\nFrom published commit {}",
original_message,
&args.source[..args.source.len().min(12)]
);
let commit_result = commit_workspace(CommitOptions {
seal: SealOptions {
ctx: pr_ctx,
shard_map: ShardMap::new(64),
content_key: None,
parent_content_key: None,
},
message: commit_message,
parent_cid: Some(void_core::crypto::CommitCid::from_bytes(source_cid_bytes)),
allow_data_loss: false,
foreign_parent: true,
})
.map_err(void_err_to_cli)?;
refs::write_branch(void_dir_utf8, &branch_name, &commit_result.commit_cid)
.map_err(void_err_to_cli)?;
let commit_cid_str = commit_result.commit_cid.to_cid_string();
let total_files = commit_result.total_files.unwrap_or(files_extracted as u64);
let total_bytes = commit_result.total_bytes.unwrap_or(0);
if !ctx.use_json() {
ctx.info(format!("Created branch '{}'", branch_name));
ctx.info(format!(
" Source: {}",
&args.source[..args.source.len().min(20)]
));
ctx.info(format!(" Files: {}", total_files));
ctx.info(format!(" Size: {}", format_bytes(total_bytes)));
ctx.info(String::new());
ctx.info(format!("To review: void diff {}", branch_name));
ctx.info(format!("To merge: void merge {}", branch_name));
}
Ok(PullRequestOutput {
branch: branch_name,
source_cid: args.source.clone(),
commit_cid: commit_cid_str,
files: total_files as usize,
original_message,
})
})
}
fn extract_foreign_files(
store: &FsStore,
commit: &metadata::Commit,
reader: &CommitReader,
owner_vault: &KeyVault,
target_dir: &Path,
void_dir: &Path,
observer: &Arc<ProgressObserver>,
) -> Result<usize, CliError> {
let manifest = void_core::metadata::manifest_tree::TreeManifest::from_commit(store, commit, reader)
.map_err(void_err_to_cli)?
.ok_or_else(|| CliError::internal("commit has no manifest_cid"))?;
let shards = manifest.shards();
let mut files_to_restore = Vec::new();
for entry_result in manifest.iter() {
let entry = entry_result.map_err(void_err_to_cli)?;
let shard_ref = shards.get(entry.shard_index as usize)
.ok_or_else(|| CliError::internal(format!("shard_index {} out of range", entry.shard_index)))?;
files_to_restore.push(FileToRestore {
entry,
shard_cid: shard_ref.cid.clone(),
wrapped_key: shard_ref.wrapped_key.clone(),
});
}
let obs: Option<Arc<dyn VoidObserver>> = Some(observer.clone() as Arc<dyn VoidObserver>);
let (result, _) = restore_files(
store,
reader,
owner_vault.staged_key().map_err(|e| void_err_to_cli(e.into()))?,
&[], target_dir,
&files_to_restore,
&obs,
Some(void_dir),
)
.map_err(void_err_to_cli)?;
Ok(result.files_restored)
}