use chrono::{Local, Utc};
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
};
use crate::{
Manifest, Record,
archive::{
archive_path_matches, create_archive, create_private_dir, portable_path_key,
remove_file_if_same, safe_windows_component, write_new_file,
},
expand, human,
project::{discover, inspect_recovery},
resolve_for_creation,
};
const VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) fn backup_cmd(
roots: &[PathBuf],
dest: Option<PathBuf>,
dry: bool,
include_artifacts: bool,
always: bool,
exclude_git_metadata: bool,
) -> anyhow::Result<()> {
let dest = dest
.filter(|path| !path.as_os_str().is_empty())
.ok_or_else(|| anyhow::anyhow!("set BACKUP_DEST or pass --backup-dest"))?;
let dest = resolve_for_creation(&expand(&dest))?;
if dest.exists() && !dest.is_dir() {
anyhow::bail!("destination is not a directory: {}", dest.display())
}
let mut projects = Vec::new();
let mut seen = HashSet::new();
let mut failed = false;
for root in roots {
match discover(root, false) {
Ok(ps) => {
for p in ps {
if seen.insert(p.path.clone()) {
projects.push(p)
}
}
}
Err(e) => {
eprintln!("error: {e}");
failed = true
}
}
}
if projects.is_empty() {
anyhow::bail!("No projects found")
}
for project in &projects {
anyhow::ensure!(
safe_windows_component(&project.name),
"unsafe project name: {}",
project.name
);
}
let mut name_counts = HashMap::new();
for project in &projects {
*name_counts
.entry(portable_path_key(&project.name))
.or_insert(0) += 1;
}
let project_dirs: Vec<_> = projects
.iter()
.map(|project| {
if name_counts[&portable_path_key(&project.name)] > 1 {
let path_hash = format!(
"{:x}",
Sha256::digest(project.path.to_string_lossy().as_bytes())
);
format!("{}-{}", project.name, &path_hash[..8])
} else {
project.name.clone()
}
})
.collect();
for (project, dir_name) in projects.iter().zip(&project_dirs) {
let output_dir = resolve_for_creation(&dest.join(dir_name))?;
anyhow::ensure!(
!output_dir.starts_with(&project.path),
"backup destination is inside project: {} -> {}",
output_dir.display(),
project.path.display()
);
}
if !dry {
create_private_dir(&dest)?;
}
let timestamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
for (p, dir_name) in projects.iter().zip(&project_dirs) {
let dir = dest.join(dir_name);
let mut run_stamp = timestamp.clone();
let mut suffix = 2;
while dir.join(format!("manifest-{run_stamp}.json")).exists()
|| dir.join(format!("{}-{run_stamp}.tar.gz", p.name)).exists()
{
run_stamp = format!("{timestamp}-{suffix}");
suffix += 1;
}
let manifest_path = dir.join(format!("manifest-{run_stamp}.json"));
let archive_path = dir.join(format!("{}-{run_stamp}.tar.gz", p.name));
let (recovery, fallback_reason) = if include_artifacts {
(None, Some("include_artifacts".to_owned()))
} else if always {
(None, Some("always_archive".to_owned()))
} else {
match inspect_recovery(p) {
Ok(recovery) => (Some(recovery), None),
Err(reason) => (None, Some(reason)),
}
};
let blocked = !include_artifacts
&& !always
&& fallback_reason.as_deref().is_some_and(|reason| {
reason.starts_with("remote URL contains credentials or query data")
|| reason.starts_with("submodule URL contains credentials or query data")
|| reason.starts_with("Git config contains HTTP extraHeader")
|| reason.starts_with("Git config contains credential passwords")
|| reason.starts_with("Git config contains an inline credential helper")
|| reason
.starts_with("Git config contains sensitive credential helper arguments")
|| reason.starts_with("Git config contains a custom SSH command")
|| reason
.starts_with("Git config points to a credential file inside the project")
|| reason.starts_with(
"Git config contains credentials or query data in a service URL",
)
|| reason.starts_with("Git config contains credentials in a URL rewrite")
|| reason.starts_with("LFS config contains credentials or query data")
|| reason.starts_with("cannot safely inspect LFS config for credentials")
|| reason.starts_with("cannot safely inspect .gitmodules for credentials")
|| reason.starts_with("cannot safely inspect Git config for credentials")
});
if blocked {
let reason = fallback_reason.as_deref().unwrap_or_default();
if dry {
println!("[error] {}: {reason}", p.name);
} else {
eprintln!("error: {}: {reason}", p.name);
}
failed = true;
continue;
}
if dry {
if let Some(git) = &recovery {
println!(
"[git] {}: {} @ {}",
p.name,
git.remote_url,
&git.commit[..12.min(git.commit.len())]
);
} else {
let reason = fallback_reason
.as_deref()
.map(|reason| format!(" ({reason})"))
.unwrap_or_default();
println!(
"[archive] {}: {} -> {}{}{}",
p.name,
human(p.size_bytes),
archive_path.display(),
reason,
if exclude_git_metadata {
" [Git metadata exclusion enabled; restored archive has no Git metadata]"
} else {
""
}
);
}
continue;
}
let mut archive_identity = None;
let mut manifest_identity = None;
let result = (|| -> anyhow::Result<Record> {
create_private_dir(&dir)?;
if let Some(git) = &recovery {
return Ok(Record {
project: p.name.clone(),
source: p.path.to_string_lossy().into(),
archive: None,
archive_bytes: 0,
source_bytes: p.size_bytes,
sha256: None,
strategy: "git".into(),
remote: Some(git.remote.clone()),
remote_url: Some(git.remote_url.clone()),
branch: Some(git.branch.clone()),
commit: Some(git.commit.clone()),
upstream: Some(git.upstream.clone()),
verified: Some("local-ref".into()),
fallback_reason: None,
});
}
let (hash, bytes, identity) = create_archive(
&p.path,
&archive_path,
include_artifacts,
exclude_git_metadata,
)?;
archive_identity = Some(identity);
Ok(Record {
project: p.name.clone(),
source: p.path.to_string_lossy().into(),
archive: Some(archive_path.to_string_lossy().into()),
archive_bytes: bytes,
source_bytes: p.size_bytes,
sha256: Some(hash),
strategy: "archive".into(),
remote: None,
remote_url: None,
branch: None,
commit: None,
upstream: None,
verified: None,
fallback_reason: fallback_reason.clone(),
})
})();
match result {
Ok(record) => {
let manifest = Manifest {
schema_version: 2,
created_at: Utc::now().to_rfc3339(),
tool: "proj-janitor".into(),
version: VERSION.into(),
include_artifacts,
always_archive: always,
exclude_git_metadata: exclude_git_metadata && record.strategy == "archive",
records: vec![record.clone()],
};
let write_result = (|| -> anyhow::Result<()> {
if let Some(identity) = &archive_identity {
anyhow::ensure!(
archive_path_matches(&archive_path, identity),
"archive path changed before manifest write: {}",
archive_path.display()
);
}
let mut bytes = serde_json::to_vec_pretty(&manifest)?;
bytes.push(b'\n');
manifest_identity = Some(write_new_file(&manifest_path, &bytes)?);
if let Some(identity) = &archive_identity {
anyhow::ensure!(
archive_path_matches(&archive_path, identity),
"archive path changed while manifest was written: {}",
archive_path.display()
);
}
Ok(())
})();
if let Err(e) = write_result {
if let Some(identity) = manifest_identity.take() {
remove_file_if_same(&manifest_path, identity);
}
if let Some(identity) = archive_identity.take() {
remove_file_if_same(&archive_path, identity);
}
eprintln!("error: {}: cannot write manifest: {e}", p.name);
failed = true;
continue;
}
drop(manifest_identity.take());
if record.strategy == "git" {
println!(
"[git] {}: {} @ {}",
record.project,
record.remote_url.as_deref().unwrap_or("-"),
&record.commit.as_deref().unwrap_or_default()
[..12.min(record.commit.as_deref().unwrap_or_default().len())]
);
} else {
let reason = record
.fallback_reason
.as_deref()
.map(|reason| format!(" ({reason})"))
.unwrap_or_default();
println!(
"[archive] {}: {} sha256:{}{}{}",
record.project,
human(record.archive_bytes),
&record.sha256.as_deref().unwrap_or_default()[..12],
reason,
if exclude_git_metadata {
" [Git metadata exclusion enabled; restored archive has no Git metadata]"
} else {
""
}
);
}
println!("Manifest: {}", manifest_path.display());
}
Err(e) => {
if let Some(identity) = archive_identity.take() {
remove_file_if_same(&archive_path, identity);
}
eprintln!("error: {}: {e}", p.name);
failed = true
}
}
}
if failed {
anyhow::bail!("one or more backup operations failed")
}
Ok(())
}