use anyhow::{bail, Context, Result};
use colored::Colorize;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Command, Stdio};
use super::hardening::{self, HardeningStatus};
use crate::converter::format;
use crate::utils::config::{self, BendisConfig};
#[derive(Default)]
struct ConflictCleanupResult {
removed: usize,
preserved: Vec<String>,
}
pub fn run(hard: bool) -> Result<()> {
if config::check_and_migrate_if_needed()? {
if !config::get_bendis_dir().exists() {
bail!(
"error: bendis_workspace directory not found\nrun `bendis init` to migrate or initialize the project"
);
}
}
println!("Updating dependencies...");
let cfg = BendisConfig::load()?;
let bendis_dir = config::get_bendis_dir();
let root_dir = config::get_root_dir();
if !bendis_dir.exists() {
bail!(
"error: bendis_workspace directory not found\nrun `bendis init` first to initialize the project"
);
}
let mut gitignore_warning = None;
if cfg.gitignore_check == 1 {
if let Err(e) = config::check_bendis_gitignore() {
gitignore_warning = Some(e.to_string());
}
}
println!(" Syncing hw/ and target/ directories to bendis_workspace/...");
config::copy_root_dirs_to_bendis_workspace()?;
restore_root_override_for_workspace_update(&bendis_dir, &root_dir)?;
println!(" Preparing cache files (it may take long)...");
run_bender_update_in_bendis(&bendis_dir, cfg.silent_mode == 1)?;
println!(" Converting URLs...");
let configs_changed = format::convert(&bendis_dir, &root_dir)?;
println!(" Updating bendis_workspace/.gitignore...");
config::ensure_bendis_workspace_gitignore_entries()?;
println!(" Updating root .gitignore...");
config::ensure_root_gitignore_excludes_hw_target()?;
println!(" Checking for changes...");
if configs_changed {
println!(" Detected changes, checking out dependencies...");
} else {
println!(" No changes detected, checking local dependency cache...");
}
run_bender_checkout_in_root(&root_dir, Path::new("bender"))?;
println!(" Cleaning up cache...");
cleanup_bendis_workspace_bender_dir(&bendis_dir, cfg.storage_saving_mode == 1)?;
verify_completion(&root_dir)?;
if let Some(warning_msg) = gitignore_warning {
eprintln!("\n{}", "warning:".yellow().bold());
eprintln!(" {}", warning_msg);
eprintln!(" Some files in bendis_workspace/ may be tracked by git unexpectedly.");
eprintln!(" Please review bendis_workspace/.gitignore and ensure it contains the required entries.");
eprintln!(
" To disable this check, set 'gitignore_check = 0' in: {}",
"bendis config".cyan()
);
}
if hard {
println!("Preparing AegisRTL workspace...");
let result = match hardening::run(&root_dir) {
Ok(result) => result,
Err(error) => {
eprintln!("Hardening failed; local RTL dependencies were not activated");
return Err(error);
}
};
for message in hardening_completion_messages(&result) {
println!("{message}");
}
}
let cleanup = cleanup_generated_conflict_copies(&root_dir, &bendis_dir, hard)?;
if cleanup.removed > 0 {
println!("Removed generated conflict copies: {}", cleanup.removed);
}
for path in cleanup.preserved {
eprintln!("Warning: possible workspace conflict copy was not removed: {path}");
}
println!("Done");
Ok(())
}
fn hardening_completion_messages(result: &hardening::HardeningResult) -> Vec<String> {
match result.status {
HardeningStatus::Completed => vec![
format!(
"Hardening completed. Changed RTL files: {}",
result.changed_files.len()
),
"Hardened RTL dependencies activated".to_string(),
],
HardeningStatus::Skipped => vec![
"Hardening skipped: aegisrtl/scripts/harden.sh was not found".to_string(),
"Local RTL dependencies activated without hardening".to_string(),
],
}
}
fn restore_root_override_for_workspace_update(bendis_dir: &Path, root_dir: &Path) -> Result<()> {
let source = bendis_dir.join(".bender.yml");
if !source.is_file() {
bail!("error: bendis_workspace/.bender.yml not found");
}
fs::copy(&source, root_dir.join(".bender.yml"))
.context("Failed to restore root .bender.yml from bendis_workspace")?;
Ok(())
}
fn run_bender_update_in_bendis(bendis_dir: &Path, silent: bool) -> Result<()> {
let mut cmd = Command::new("bender");
cmd.args(&["-d", "./bendis_workspace", "update"]);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.context("Failed to run bender. Is bender installed and in PATH?")?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
let mut stdout_reader = BufReader::new(stdout);
let mut stderr_reader = BufReader::new(stderr);
let mut log_buffer = Vec::new();
let mut line = String::new();
while stdout_reader.read_line(&mut line).unwrap_or(0) > 0 {
if !silent {
print!("{}", line);
}
log_buffer.push(line.clone());
line.clear();
}
while stderr_reader.read_line(&mut line).unwrap_or(0) > 0 {
if !silent {
eprint!("{}", line);
}
log_buffer.push(line.clone());
line.clear();
}
let status = child.wait().context("Failed to wait for bender process")?;
if !status.success() {
eprintln!(
"\n{}",
"Error: bender update failed in bendis_workspace/".red()
);
eprintln!("\nCommand output:");
for log_line in &log_buffer {
eprint!("{}", log_line);
}
let has_fetch_error = log_buffer.iter().any(|line| {
line.contains("Fetching Dependency")
&& line.contains("cannot satisfy requirement")
&& line.contains("may need fetch")
});
if has_fetch_error {
eprintln!("\n{}", "Possible solution:".yellow());
eprintln!(
" 1. Delete {} directory",
"bendis_workspace/.bender/".cyan()
);
eprintln!(" 2. Check your internet connection");
eprintln!(" 3. Run {} again", "bendis update".cyan());
}
bail!("error: bender update in bendis_workspace/ failed");
}
let lock_file = bendis_dir.join("Bender.lock");
if !lock_file.exists() {
bail!("error: failed to generate bendis_workspace/Bender.lock");
}
Ok(())
}
fn run_bender_checkout_in_root(root_dir: &Path, bender: &Path) -> Result<()> {
let local = Command::new(bender)
.args(["--local", "--git-submodules", "false", "checkout"])
.current_dir(root_dir)
.output()
.context("Failed to run bender. Is bender installed and in PATH?")?;
if local.status.success() && checkouts_have_complete_submodules(root_dir)? {
return Ok(());
}
println!(" Local checkout could not be completed; retrying with remote access");
let remote = Command::new(bender)
.arg("checkout")
.current_dir(root_dir)
.output()
.context("Failed to retry bender checkout with remote access")?;
if !remote.status.success() {
eprint!("{}", String::from_utf8_lossy(&remote.stdout));
eprint!("{}", String::from_utf8_lossy(&remote.stderr));
bail!("bender checkout failed in root directory");
}
if !checkouts_have_complete_submodules(root_dir)? {
bail!("bender checkout did not produce complete dependency submodules");
}
Ok(())
}
fn checkouts_have_complete_submodules(root_dir: &Path) -> Result<bool> {
let checkouts = root_dir.join(".bender/git/checkouts");
if !checkouts.is_dir() {
return Ok(true);
}
for entry in fs::read_dir(&checkouts)
.with_context(|| format!("Failed to read {}", checkouts.display()))?
{
let path = entry?.path();
if !path.is_dir() || !path.join(".git").exists() {
continue;
}
let submodules = Command::new("git")
.args(["submodule", "status", "--recursive"])
.current_dir(&path)
.output()
.with_context(|| format!("Failed to inspect submodules in {}", path.display()))?;
if !submodules.status.success()
|| submodules
.stdout
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.any(|line| line.first() != Some(&b' '))
{
return Ok(false);
}
}
Ok(true)
}
fn cleanup_bendis_workspace_bender_dir(bendis_dir: &Path, full_cleanup: bool) -> Result<()> {
if full_cleanup {
let bender_dir = bendis_dir.join(".bender");
if bender_dir.exists() {
fs::remove_dir_all(&bender_dir)
.context("Failed to remove bendis_workspace/.bender/")?;
}
} else {
}
Ok(())
}
fn verify_completion(root_dir: &Path) -> Result<()> {
let mut missing = Vec::new();
if !root_dir.join("Bender.yml").exists() {
missing.push("Bender.yml");
}
if !root_dir.join(".bender.yml").exists() {
missing.push(".bender.yml");
}
if !root_dir.join("Bender.lock").exists() {
missing.push("Bender.lock");
}
if !root_dir.join(".bender").exists() {
missing.push(".bender/");
}
if !missing.is_empty() {
bail!(
"error: verification failed, missing: {}",
missing.join(", ")
);
}
Ok(())
}
fn cleanup_generated_conflict_copies(
root_dir: &Path,
bendis_dir: &Path,
hard: bool,
) -> Result<ConflictCleanupResult> {
let mut result = ConflictCleanupResult::default();
for canonical in ["Bender.yml", ".bender.yml", "Bender.lock"] {
result.removed += remove_numbered_file_copies(root_dir, canonical)?;
}
result.removed += remove_numbered_file_copies(bendis_dir, "Bender.lock")?;
for canonical in ["Bender.yml", ".bender.yml"] {
for path in numbered_file_copies(bendis_dir, canonical)? {
let directory = bendis_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("bendis_workspace");
let file_name = path.file_name().and_then(|name| name.to_str()).unwrap();
result.preserved.push(format!("{directory}/{file_name}"));
}
}
if hard {
let root = root_dir
.canonicalize()
.with_context(|| format!("Failed to resolve {}", root_dir.display()))?;
let aegis = root
.parent()
.context("project root has no parent directory")?
.join("aegisrtl");
for canonical in ["Bender.yml", ".bender.yml", "Bender.lock"] {
result.removed += remove_numbered_file_copies(&aegis, canonical)?;
}
}
result.preserved.sort();
Ok(result)
}
fn remove_numbered_file_copies(directory: &Path, canonical: &str) -> Result<usize> {
let copies = numbered_file_copies(directory, canonical)?;
for path in &copies {
fs::remove_file(path)?;
}
Ok(copies.len())
}
fn numbered_file_copies(directory: &Path, canonical: &str) -> Result<Vec<std::path::PathBuf>> {
if !directory.join(canonical).is_file() {
return Ok(Vec::new());
}
let (stem, extension) = canonical
.rsplit_once('.')
.context("generated file name has no extension")?;
let mut copies = Vec::new();
for entry in fs::read_dir(directory)
.with_context(|| format!("Failed to scan {}", directory.display()))?
{
let entry = entry?;
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str() else {
continue;
};
let Some(number) = file_name
.strip_prefix(stem)
.and_then(|suffix| suffix.strip_prefix(' '))
.and_then(|suffix| suffix.strip_suffix(&format!(".{extension}")))
else {
continue;
};
let metadata = fs::symlink_metadata(entry.path())?;
let mut digits = number.bytes();
if digits
.next()
.is_some_and(|byte| byte.is_ascii_digit() && byte != b'0')
&& digits.all(|byte| byte.is_ascii_digit())
&& metadata.file_type().is_file()
{
copies.push(entry.path());
}
}
copies.sort();
Ok(copies)
}
#[cfg(test)]
mod tests {
use super::{
cleanup_generated_conflict_copies, hardening_completion_messages,
restore_root_override_for_workspace_update, run_bender_checkout_in_root,
};
use crate::commands::hardening::{HardeningResult, HardeningStatus};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn successful_update_removes_numbered_root_override_copy() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root: PathBuf = std::env::temp_dir().join(format!("bendis-conflict-{nonce}"));
let workspace = root.join("bendis_workspace");
fs::create_dir_all(&workspace).unwrap();
fs::write(root.join(".bender.yml"), "overrides: {}\n").unwrap();
fs::write(root.join(".bender 2.yml"), "stale\n").unwrap();
let result = cleanup_generated_conflict_copies(&root, &workspace, false).unwrap();
assert_eq!(result.removed, 1);
assert!(root.join(".bender.yml").is_file());
assert!(!root.join(".bender 2.yml").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn cleanup_removes_workspace_lock_copy_but_preserves_workspace_config_copy() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root: PathBuf = std::env::temp_dir().join(format!("bendis-conflict-{nonce}"));
let workspace = root.join("bendis_workspace");
fs::create_dir_all(&workspace).unwrap();
fs::write(workspace.join("Bender.lock"), "packages: {}\n").unwrap();
fs::write(workspace.join("Bender 2.lock"), "stale\n").unwrap();
fs::write(workspace.join("Bender.yml"), "dependencies: {}\n").unwrap();
fs::write(workspace.join("Bender 2.yml"), "possible user changes\n").unwrap();
let result = cleanup_generated_conflict_copies(&root, &workspace, false).unwrap();
assert_eq!(result.removed, 1);
assert_eq!(result.preserved, vec!["bendis_workspace/Bender 2.yml"]);
assert!(!workspace.join("Bender 2.lock").exists());
assert!(workspace.join("Bender 2.yml").is_file());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn hard_cleanup_removes_numbered_aegis_generated_file_copy() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base: PathBuf = std::env::temp_dir().join(format!("bendis-conflict-{nonce}"));
let root = base.join("heris-soc");
let workspace = root.join("bendis_workspace");
let aegis = base.join("aegisrtl");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&aegis).unwrap();
fs::write(aegis.join("Bender.lock"), "packages: {}\n").unwrap();
fs::write(aegis.join("Bender 3.lock"), "stale\n").unwrap();
let normal = cleanup_generated_conflict_copies(&root, &workspace, false).unwrap();
assert_eq!(normal.removed, 0);
assert!(aegis.join("Bender 3.lock").is_file());
let hard = cleanup_generated_conflict_copies(&root, &workspace, true).unwrap();
assert_eq!(hard.removed, 1);
assert!(!aegis.join("Bender 3.lock").exists());
fs::remove_dir_all(base).unwrap();
}
#[cfg(unix)]
#[test]
fn cleanup_ignores_non_conflict_names_directories_and_symlinks() {
use std::os::unix::fs::symlink;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root: PathBuf = std::env::temp_dir().join(format!("bendis-conflict-{nonce}"));
let workspace = root.join("bendis_workspace");
fs::create_dir_all(root.join("Bender 4.yml")).unwrap();
fs::create_dir_all(&workspace).unwrap();
fs::write(root.join("Bender.yml"), "dependencies: {}\n").unwrap();
fs::write(root.join("Bender 02.yml"), "keep\n").unwrap();
fs::write(root.join("Bender copy.yml"), "keep\n").unwrap();
symlink(root.join("Bender.yml"), root.join("Bender 5.yml")).unwrap();
let result = cleanup_generated_conflict_copies(&root, &workspace, false).unwrap();
assert_eq!(result.removed, 0);
assert!(root.join("Bender 02.yml").is_file());
assert!(root.join("Bender copy.yml").is_file());
assert!(root.join("Bender 4.yml").is_dir());
assert!(fs::symlink_metadata(root.join("Bender 5.yml"))
.unwrap()
.file_type()
.is_symlink());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn workspace_update_restores_root_override_before_running_bender() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root: PathBuf = std::env::temp_dir().join(format!("bendis-restore-{nonce}"));
let workspace = root.join("bendis_workspace");
fs::create_dir_all(&workspace).unwrap();
fs::write(
root.join(".bender.yml"),
"overrides:\n core: { path: '../aegisrtl/core' }\n",
)
.unwrap();
fs::write(
workspace.join(".bender.yml"),
"overrides:\n core: { git: 'ssh://ihep/core.git', rev: abc }\n",
)
.unwrap();
restore_root_override_for_workspace_update(&workspace, &root).unwrap();
assert_eq!(
fs::read_to_string(root.join(".bender.yml")).unwrap(),
fs::read_to_string(workspace.join(".bender.yml")).unwrap()
);
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn root_checkout_uses_local_cache_without_removing_it() {
use std::os::unix::fs::PermissionsExt;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("bendis-root-checkout-{nonce}"));
let bin = root.join("fake-bender");
let log = root.join("calls.log");
fs::create_dir_all(root.join(".bender/git/db/core")).unwrap();
fs::write(root.join(".bender/git/db/core/marker"), "keep\n").unwrap();
fs::write(
&bin,
format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n", log.display()),
)
.unwrap();
let mut permissions = fs::metadata(&bin).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&bin, permissions).unwrap();
run_bender_checkout_in_root(&root, &bin).unwrap();
assert_eq!(
fs::read_to_string(&log).unwrap(),
"--local --git-submodules false checkout\n"
);
assert!(root.join(".bender/git/db/core/marker").is_file());
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn root_checkout_retries_once_with_remote_access() {
use std::os::unix::fs::PermissionsExt;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("bendis-root-retry-{nonce}"));
let bin = root.join("fake-bender");
let log = root.join("calls.log");
fs::create_dir_all(&root).unwrap();
fs::write(
&bin,
format!(
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n[ \"$1\" != \"--local\" ]\n",
log.display()
),
)
.unwrap();
let mut permissions = fs::metadata(&bin).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&bin, permissions).unwrap();
run_bender_checkout_in_root(&root, &bin).unwrap();
assert_eq!(
fs::read_to_string(&log).unwrap(),
"--local --git-submodules false checkout\ncheckout\n"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn skipped_hardening_reports_unmodified_local_rtl_activation() {
let result = HardeningResult {
status: HardeningStatus::Skipped,
changed_files: Vec::new(),
};
assert_eq!(
hardening_completion_messages(&result),
vec![
"Hardening skipped: aegisrtl/scripts/harden.sh was not found".to_string(),
"Local RTL dependencies activated without hardening".to_string(),
]
);
}
}