void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Reparent a commit to have different parent(s).
//!
//! Creates a new commit with the same content but different parents.
//! Preserves the VD01 envelope nonce so that metadata/shards encrypted
//! with the original content_key remain decryptable.
//!
//! This is useful for recovery scenarios where history needs to be
//! reconnected after data loss or corruption.

use std::path::Path;

use serde::Serialize;
use void_core::cid;
use void_core::crypto::{self, MAGIC_V1};
use void_core::metadata::parse_commit;
use void_core::store::{FsStore, ObjectStoreExt};

use crate::context::{load_signing_key, open_repo, signing_key_exists};
use crate::output::{run_command, CliError, CliOptions};

/// Command-line arguments for reparent.
#[derive(Debug)]
pub struct ReparentArgs {
    /// CID of the commit to reparent.
    pub commit_cid: String,
    /// New parent CID(s) for the commit.
    pub new_parent_cids: Vec<String>,
}

/// JSON output for the reparent command.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReparentOutput {
    /// Original commit CID.
    pub old_cid: String,
    /// New commit CID after reparenting.
    pub new_cid: String,
    /// Parent CIDs that were set.
    pub parents: Vec<String>,
    /// Whether the new commit was signed.
    pub signed: bool,
}

/// Run the reparent command.
///
/// # Arguments
///
/// * `cwd` - Current working directory
/// * `args` - Reparent arguments
/// * `opts` - CLI options
pub fn run(cwd: &Path, args: ReparentArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("reparent", opts, |ctx| {
        let repo = open_repo(cwd)?;
        let vault = repo.vault();
        let objects_dir = repo.void_dir().join("objects");
        let store = FsStore::new(objects_dir)
            .map_err(|e| CliError::internal(format!("failed to open object store: {}", e)))?;

        ctx.progress(format!("Loading commit {}...", args.commit_cid));

        // Parse and load the original commit
        let parsed_cid = cid::parse(&args.commit_cid)
            .map_err(|e| CliError::invalid_args(format!("invalid commit CID: {}", e)))?;

        let encrypted_blob: crypto::EncryptedCommit = store
            .get_blob(&parsed_cid)
            .map_err(|e| CliError::not_found(format!("failed to load commit: {}", e)))?;
        let encrypted = encrypted_blob.as_bytes();

        // Extract key_nonce from VD01 envelope if present (bytes 4..20).
        // This is critical: we must preserve the key_nonce so the derived content_key
        // stays the same, otherwise metadata/shards encrypted with the original
        // content_key won't decrypt.
        let original_nonce: Option<crypto::KeyNonce> =
            if encrypted.len() > 20 && encrypted.starts_with(MAGIC_V1) {
                crypto::KeyNonce::from_bytes(&encrypted[4..20])
            } else {
                None
            };

        // Decrypt the commit
        let (decrypted, _nonce) = vault.unseal_commit_with_nonce(&encrypted)
            .map_err(|e| CliError::internal(format!("failed to decrypt commit: {}", e)))?;

        // Parse the commit
        let mut commit = parse_commit(&decrypted)
            .map_err(|e| CliError::internal(format!("failed to parse commit: {}", e)))?;

        // Parse new parent CIDs
        let new_parent_bytes: Vec<Vec<u8>> = args
            .new_parent_cids
            .iter()
            .map(|cid_str| {
                let parent = cid::parse(cid_str).map_err(|e| {
                    CliError::invalid_args(format!("invalid parent CID '{}': {}", cid_str, e))
                })?;
                Ok(cid::to_bytes(&parent))
            })
            .collect::<Result<Vec<_>, CliError>>()?;

        ctx.progress("Reparenting commit...");

        // Update parents
        commit.parents = new_parent_bytes.into_iter().map(void_core::crypto::CommitCid::from_bytes).collect();


        // Clear old signature (content changed, signature is invalid)
        commit.author = None;
        commit.signature = None;

        // Re-sign if signing key is available
        let signed = if signing_key_exists() {
            let signing_key = load_signing_key()?;
            commit.sign(&signing_key);
            true
        } else {
            false
        };

        // Serialize the modified commit with CBOR
        let mut new_bytes = Vec::new();
        ciborium::into_writer(&commit, &mut new_bytes)
            .map_err(|e| CliError::internal(format!("failed to serialize commit: {}", e)))?;

        // Re-encrypt preserving the original nonce (or generate new one for legacy)
        let nonce = original_nonce.unwrap_or_else(crypto::generate_key_nonce);
        let new_encrypted = vault.seal_commit_with_nonce(&new_bytes, &nonce)
            .map_err(|e| CliError::internal(format!("failed to encrypt commit: {}", e)))?;

        // Store the new commit
        let new_cid = store
            .put_blob(&new_encrypted)
            .map_err(|e| CliError::internal(format!("failed to store commit: {}", e)))?;
        let new_cid_str = new_cid.to_string();

        if !ctx.use_json() {
            ctx.info(format!("Old CID: {}", args.commit_cid));
            ctx.info(format!("New CID: {}", new_cid_str));
            ctx.info(format!(
                "Parents: {}",
                if args.new_parent_cids.is_empty() {
                    "(none - initial commit)".to_string()
                } else {
                    args.new_parent_cids.join(", ")
                }
            ));
            ctx.info(format!("Signed: {}", signed));
        }

        Ok(ReparentOutput {
            old_cid: args.commit_cid,
            new_cid: new_cid_str,
            parents: args.new_parent_cids,
            signed,
        })
    })
}