use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{audit::AuditEntry, profile};
use crate::helpers::*;
pub(crate) fn cmd_sync(
profile: &str,
remote: &str,
branch: &str,
vault_file_override: Option<&str>,
dry_run: bool,
) -> Result<()> {
use std::process::Command;
use tsafe_core::sync::three_way_merge;
use tsafe_core::vault::VaultFile;
let repo_root = String::from_utf8(
Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("failed to run git — is this a git repository?")?
.stdout,
)?
.trim()
.to_string();
let vault_path = profile::vault_path(profile);
let rel_path = vault_file_override.map(String::from).unwrap_or_else(|| {
vault_path
.strip_prefix(&repo_root)
.or_else(|_| vault_path.strip_prefix("/"))
.unwrap_or(&vault_path)
.to_string_lossy()
.replace('\\', "/")
});
println!("Syncing vault: {rel_path} ({remote}/{branch})");
if vault_path.exists() {
let _ = tsafe_core::snapshot::take(
&vault_path,
profile,
tsafe_core::snapshot::DEFAULT_SNAPSHOT_KEEP,
);
}
let fetch = Command::new("git")
.args(["fetch", remote, branch])
.status()
.context("git fetch failed")?;
if !fetch.success() {
anyhow::bail!("git fetch {remote} {branch} failed");
}
let remote_ref = format!("{remote}/{branch}");
let merge_base_output = Command::new("git")
.args(["merge-base", "HEAD", &remote_ref])
.output()
.context("git merge-base failed")?;
let has_base = merge_base_output.status.success();
let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
.trim()
.to_string();
let base: VaultFile = if has_base {
let base_json = Command::new("git")
.args(["show", &format!("{merge_base}:{rel_path}")])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string());
match base_json {
Some(json) => serde_json::from_str(&json)
.context("failed to parse base vault from git history")?,
None => serde_json::from_str("{}").unwrap_or_else(|_| empty_vault_file()),
}
} else {
empty_vault_file()
};
let theirs_json = Command::new("git")
.args(["show", &format!("{remote_ref}:{rel_path}")])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).to_string());
let theirs: VaultFile = match theirs_json {
Some(json) => serde_json::from_str(&json).context("failed to parse remote vault")?,
None => {
println!(" Vault file not found on remote — will push local vault.");
empty_vault_file()
}
};
let ours: VaultFile = if vault_path.exists() {
let json = std::fs::read_to_string(&vault_path)?;
serde_json::from_str(&json).context("failed to parse local vault")?
} else {
println!(" Vault file not found locally — will pull from remote.");
empty_vault_file()
};
let result = three_way_merge(&base, &ours, &theirs)?;
if result.is_noop {
println!("{} Already in sync — nothing to do.", "✓".green());
return Ok(());
}
println!();
if !result.added_from_theirs.is_empty() {
println!(
" {} keys added from remote: {}",
result.added_from_theirs.len(),
result.added_from_theirs.join(", ")
);
}
if !result.updated_from_theirs.is_empty() {
println!(
" {} keys updated from remote: {}",
result.updated_from_theirs.len(),
result.updated_from_theirs.join(", ")
);
}
if !result.added_from_ours.is_empty() {
println!(
" {} keys added from local: {}",
result.added_from_ours.len(),
result.added_from_ours.join(", ")
);
}
if !result.deleted.is_empty() {
println!(
" {} keys deleted: {}",
result.deleted.len(),
result.deleted.join(", ")
);
}
if !result.conflicts.is_empty() {
println!(
" {} {} (resolved by last-write-wins): {}",
"⚠".yellow(),
format!("{} conflicts", result.conflicts.len()).yellow(),
result.conflicts.join(", ")
);
}
if dry_run {
println!("\n{} Dry run — no changes made.", "i".blue());
return Ok(());
}
let json = serde_json::to_string_pretty(&result.merged)?;
let tmp = vault_path.with_extension("vault.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &vault_path)?;
let add_status = Command::new("git")
.args(["add", &vault_path.to_string_lossy()])
.status()
.context("git add failed")?;
if !add_status.success() {
anyhow::bail!("git add failed");
}
let total_changes = result.added_from_theirs.len()
+ result.updated_from_theirs.len()
+ result.added_from_ours.len()
+ result.deleted.len()
+ result.conflicts.len();
let commit_msg = format!(
"tsafe sync: {} changes ({} added, {} updated, {} deleted, {} conflicts)",
total_changes,
result.added_from_theirs.len() + result.added_from_ours.len(),
result.updated_from_theirs.len(),
result.deleted.len(),
result.conflicts.len(),
);
let commit_status = Command::new("git")
.args(["commit", "-m", &commit_msg])
.status()
.context("git commit failed")?;
if !commit_status.success() {
eprintln!(" git commit reported no changes — vault may already be committed.");
}
let vault = open_vault(profile).ok();
let mut push_cmd = Command::new("git");
push_cmd.args(["push", remote, branch]);
if let Some(ref v) = vault {
let pat_key = std::env::var("TSAFE_GIT_PAT_KEY").unwrap_or_else(|_| "ADO_PAT".to_string());
if let Ok(pat) = v.get(&pat_key) {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let encoded = B64.encode(format!(":{}", &*pat));
push_cmd.env("GIT_CONFIG_COUNT", "1");
push_cmd.env("GIT_CONFIG_KEY_0", "http.extraHeader");
push_cmd.env(
"GIT_CONFIG_VALUE_0",
format!("Authorization: Basic {encoded}"),
);
}
}
drop(vault);
let push_status = push_cmd.status().context("git push failed")?;
if !push_status.success() {
anyhow::bail!("git push failed — you may need to pull first or resolve upstream conflicts");
}
audit(profile)
.append(&AuditEntry::success(profile, "sync", None))
.ok();
println!("\n{} Sync complete.", "✓".green());
Ok(())
}
fn empty_vault_file() -> tsafe_core::vault::VaultFile {
use tsafe_core::vault::{KdfParams, VaultChallenge, VaultFile};
VaultFile {
schema: "tsafe/vault/v1".into(),
kdf: KdfParams {
algorithm: "argon2id".into(),
m_cost: 65536,
t_cost: 3,
p_cost: 4,
salt: String::new(),
},
cipher: "xchacha20poly1305".into(),
vault_challenge: VaultChallenge {
nonce: String::new(),
ciphertext: String::new(),
},
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
secrets: std::collections::HashMap::new(),
age_recipients: Vec::new(),
wrapped_dek: None,
}
}