use std::collections::HashSet;
use std::path::Path;
use crate::{
commands::crypto::decrypt_bytes,
utils::{
commit::{
commit_exists, load_commit, read_head, read_remote_head, write_head, write_remote_head,
},
config::load_token,
manifest::{
Manifest, clear_pending_restore, load_manifest, load_manifest_by_hash, read_applied,
read_pending_restore, set_manifest, write_applied, write_pending_restore,
},
paths::{ensure_parent_exists, normalize_path, to_native_path},
project_config::{get_remote_url, load_project_config},
storage::{download_blob, download_commit, download_manifest, fetch_remote_head},
ui::{
PassphraseResult, create_progress_bar, create_spinner, print_header, print_info,
print_kv, print_success, print_warn, prompt_file_passphrase,
},
},
};
use console::style;
pub async fn pull(remote: Option<&str>) -> anyhow::Result<()> {
let token = load_token()?;
let project = load_project_config()?;
let server = get_remote_url(&project, remote)?;
let client = reqwest::Client::new();
let remote_head_result =
fetch_remote_head(&client, &server, &token, &project.project_id).await?;
if let Some(ref remote_head) = remote_head_result {
return pull_with_commits(&client, &server, &token, &project.project_id, remote_head).await;
}
legacy_pull(&client, &server, &token, &project.project_id).await
}
#[derive(Default)]
struct RestoreOutcome {
restored: usize,
failed: Vec<String>,
}
async fn download_missing_blobs(
client: &reqwest::Client,
server: &str,
token: &str,
project_id: &str,
manifest: &Manifest,
) -> anyhow::Result<usize> {
let pb = create_progress_bar(manifest.files.len() as u64);
let mut downloaded = 0;
for hash in manifest.files.values() {
let path = Path::new(".envoy/cache").join(format!("{}.blob", hash));
if path.exists() {
pb.inc(1);
continue;
}
pb.set_message(format!("Downloading {}...", &hash[..8]));
download_blob(client, server, token, project_id, hash).await?;
downloaded += 1;
pb.inc(1);
}
pb.finish_and_clear();
if downloaded > 0 {
print_success(&format!("Downloaded {} file(s).", downloaded));
}
Ok(downloaded)
}
async fn restore_files(
manifest: &Manifest,
only: Option<&HashSet<String>>,
) -> anyhow::Result<RestoreOutcome> {
let mut outcome = RestoreOutcome::default();
let targets: Vec<(&String, &String)> = manifest
.files
.iter()
.filter(|(path, _)| only.is_none_or(|set| set.contains(*path)))
.collect();
if targets.is_empty() {
return Ok(outcome);
}
let pb = create_progress_bar(targets.len() as u64);
pb.set_message("Restoring files...");
for (file_path, hash) in targets {
let blob_path = Path::new(".envoy/cache").join(format!("{}.blob", hash));
let encrypted = match tokio::fs::read(&blob_path).await {
Ok(bytes) => bytes,
Err(e) => {
pb.suspend(|| {
print_warn(&format!("Failed to read blob for '{}': {}", file_path, e));
});
outcome.failed.push(file_path.clone());
pb.inc(1);
continue;
}
};
pb.suspend(|| {
println!();
});
let passphrase = match prompt_file_passphrase(file_path) {
Ok(PassphraseResult::Passphrase(pass)) => pass,
Ok(PassphraseResult::Skip) => {
pb.suspend(|| {
print_info(&format!("Skipping '{}'", file_path));
});
outcome.failed.push(file_path.clone());
pb.inc(1);
continue;
}
Err(e) => {
pb.suspend(|| {
print_warn(&format!(
"Failed to read passphrase for '{}': {}",
file_path, e
));
});
outcome.failed.push(file_path.clone());
pb.inc(1);
continue;
}
};
match decrypt_bytes(&encrypted, &passphrase) {
Ok(plaintext) => {
let normalized = normalize_path(file_path);
let target_path = to_native_path(&normalized);
if let Err(e) = ensure_parent_exists(&target_path) {
pb.suspend(|| {
print_warn(&format!(
"Failed to create directory for '{}': {}",
file_path, e
));
});
outcome.failed.push(file_path.clone());
pb.inc(1);
continue;
}
if let Err(e) = tokio::fs::write(&target_path, plaintext).await {
pb.suspend(|| {
print_warn(&format!(
"Failed to write '{}': {}",
target_path.display(),
e
));
});
outcome.failed.push(file_path.clone());
pb.inc(1);
continue;
}
outcome.restored += 1;
}
Err(_) => {
pb.suspend(|| {
print_warn(&format!("Wrong passphrase for '{}', skipping", file_path));
});
outcome.failed.push(file_path.clone());
}
}
pb.inc(1);
}
pb.finish_and_clear();
Ok(outcome)
}
fn finish_restore(manifest_hash: &str, outcome: &RestoreOutcome) -> anyhow::Result<()> {
if outcome.restored > 0 {
print_success(&format!("Restored {} file(s).", outcome.restored));
}
if outcome.failed.is_empty() {
clear_pending_restore();
write_applied(manifest_hash)?;
} else {
write_pending_restore(manifest_hash, &outcome.failed)?;
print_warn(&format!(
"{} file(s) were not restored (skipped or wrong passphrase).",
outcome.failed.len()
));
print_info(&format!(
"Run {} to retry them.",
style("`envy pull`").cyan()
));
}
Ok(())
}
fn pending_files_for(manifest_hash: &str) -> Option<HashSet<String>> {
read_pending_restore()
.filter(|pending| pending.manifest_hash == manifest_hash)
.map(|pending| pending.files.into_iter().collect())
}
async fn pull_with_commits(
client: &reqwest::Client,
server: &str,
token: &str,
project_id: &str,
remote_head: &str,
) -> anyhow::Result<()> {
let local_remote_head = read_remote_head();
let local_head = read_head();
let heads_synced = local_remote_head.as_deref() == Some(remote_head)
&& local_head.as_deref() == Some(remote_head);
if heads_synced {
if read_pending_restore().is_none() {
print_success("Already up to date.");
return Ok(());
}
let latest_commit = load_commit(remote_head)?;
let manifest_hash = latest_commit.manifest_hash;
let manifest = load_manifest_by_hash(&manifest_hash)?;
let only = pending_files_for(&manifest_hash);
let count = only.as_ref().map_or(manifest.files.len(), HashSet::len);
print_header(&format!("Retrying {} unrestored file(s)", count));
download_missing_blobs(client, server, token, project_id, &manifest).await?;
let outcome = restore_files(&manifest, only.as_ref()).await?;
finish_restore(&manifest_hash, &outcome)?;
return Ok(());
}
print_header("Fetching commits");
let mut commits_to_download = Vec::new();
let mut current_hash = Some(remote_head.to_string());
while let Some(hash) = current_hash {
if commit_exists(&hash) {
break; }
commits_to_download.push(hash.clone());
let spinner = create_spinner(&format!("Fetching commit {}...", &hash[..8]));
download_commit(client, server, token, project_id, &hash).await?;
spinner.finish_and_clear();
let commit = load_commit(&hash)?;
current_hash = commit.parent;
}
if !commits_to_download.is_empty() {
print_success(&format!("Fetched {} commit(s).", commits_to_download.len()));
}
let latest_commit = load_commit(remote_head)?;
let manifest_hash = &latest_commit.manifest_hash;
let manifest_blob_path = Path::new(".envoy/cache").join(format!("{}.blob", manifest_hash));
if !manifest_blob_path.exists() {
let spinner = create_spinner("Downloading manifest...");
download_manifest(client, server, token, project_id, manifest_hash).await?;
spinner.finish_and_clear();
}
set_manifest(manifest_hash)?;
let manifest = load_manifest()?;
let outcome = if manifest.files.is_empty() {
RestoreOutcome::default()
} else {
print_header(&format!("Pulling {} file(s)", manifest.files.len()));
download_missing_blobs(client, server, token, project_id, &manifest).await?;
let only = pending_files_for(manifest_hash);
restore_files(&manifest, only.as_ref()).await?
};
write_head(remote_head)?;
write_remote_head(remote_head)?;
finish_restore(manifest_hash, &outcome)?;
println!();
print_kv("HEAD", &remote_head[..12]);
print_success(&format!("Updated to commit {}.", &remote_head[..8]));
Ok(())
}
async fn legacy_pull(
client: &reqwest::Client,
server: &str,
token: &str,
project_id: &str,
) -> anyhow::Result<()> {
let manifest_hash = tokio::fs::read_to_string(".envoy/latest")
.await?
.trim()
.to_string();
let applied_matches = read_applied().as_deref() == Some(manifest_hash.as_str());
if applied_matches && read_pending_restore().is_none() {
print_success("Already up to date.");
return Ok(());
}
let manifest_blob_path = Path::new(".envoy/cache").join(format!("{}.blob", manifest_hash));
if !manifest_blob_path.exists() {
let spinner = create_spinner("Downloading manifest...");
download_manifest(client, server, token, project_id, &manifest_hash).await?;
spinner.finish_and_clear();
}
let manifest = load_manifest()?;
let outcome = if manifest.files.is_empty() {
RestoreOutcome::default()
} else {
print_header(&format!("Pulling {} file(s)", manifest.files.len()));
download_missing_blobs(client, server, token, project_id, &manifest).await?;
let only = pending_files_for(&manifest_hash);
restore_files(&manifest, only.as_ref()).await?
};
finish_restore(&manifest_hash, &outcome)?;
println!();
print_kv("Manifest", &manifest_hash[..12]);
print_success(&format!("Updated to manifest {}.", &manifest_hash[..8]));
Ok(())
}