fn apply_component_overrides(component: &Component, project: &Project) -> Component {
let overrides = match project.component_overrides.get(&component.id) {
Some(v) if v.is_object() => v,
_ => return component.clone(),
};
let mut base = match serde_json::to_value(component) {
Ok(v) => v,
Err(_) => return component.clone(),
};
if let (Some(base_obj), Some(override_obj)) = (base.as_object_mut(), overrides.as_object()) {
for (key, value) in override_obj {
if matches!(
key.as_str(),
"id" | "local_path" | "remote_path" | "aliases"
) {
continue;
}
base_obj.insert(key.clone(), value.clone());
}
}
match serde_json::from_value::<Component>(base) {
Ok(mut merged) => {
merged.id = component.id.clone();
merged
}
Err(_) => component.clone(),
}
}
fn deploy_components(
config: &DeployConfig,
project: &Project,
ctx: &RemoteProjectContext,
base_path: &str,
) -> Result<DeployOrchestrationResult> {
let loaded = load_project_components(&project.component_ids)?;
if loaded.deployable.is_empty() {
let message = if loaded.skipped.is_empty() {
"No components configured for project".to_string()
} else {
format!(
"No deployable components found — {} component(s) skipped (no build artifact or deploy strategy): {}",
loaded.skipped.len(),
loaded.skipped.join(", ")
)
};
return Err(Error::validation_invalid_argument(
"componentIds",
message,
None,
Some(vec![
"Ensure components have a buildArtifact, an extension with artifact_pattern, or deploy_strategy: \"git\"".to_string(),
format!("Check with: homeboy component show <id>"),
]),
));
}
let components = plan_components(
config,
&loaded.deployable,
&loaded.skipped,
base_path,
&ctx.client,
)?;
if components.is_empty() {
return Ok(DeployOrchestrationResult {
results: vec![],
summary: DeploySummary {
total: 0,
succeeded: 0,
failed: 0,
skipped: 0,
},
});
}
let local_versions: HashMap<String, String> = components
.iter()
.filter_map(|c| version::get_component_version(c).map(|v| (c.id.clone(), v)))
.collect();
let remote_versions = if config.outdated || config.dry_run || config.check {
fetch_remote_versions(&components, base_path, &ctx.client)
} else {
HashMap::new()
};
if config.check {
return Ok(run_check_mode(
&components,
&local_versions,
&remote_versions,
base_path,
));
}
if config.dry_run {
return Ok(run_dry_run_mode(
&components,
&local_versions,
&remote_versions,
base_path,
config,
));
}
if !config.no_pull && !config.skip_build {
sync_components(&components)?;
}
if !config.force {
check_uncommitted_changes(&components)?;
}
let tag_checkouts = if !config.head && !config.skip_build {
checkout_latest_tags(&components)?
} else {
Vec::new()
};
if let Some(ref expected) = config.expected_version {
verify_expected_version(&components, expected)?;
}
let mut results: Vec<ComponentDeployResult> = vec![];
let mut succeeded: u32 = 0;
let mut failed: u32 = 0;
for component in &components {
let component = apply_component_overrides(component, project);
let mut result = execute_component_deploy(
&component,
config,
ctx,
base_path,
project,
local_versions.get(&component.id).cloned(),
remote_versions.get(&component.id).cloned(),
);
if let Some(checkout) = tag_checkouts.iter().find(|c| c.component_id == component.id) {
result = result.with_deployed_ref(checkout.tag.clone());
} else if config.head {
if let Some(branch) = crate::utils::command::run_in_optional(
&component.local_path,
"git",
&["rev-parse", "--abbrev-ref", "HEAD"],
) {
result = result.with_deployed_ref(format!("{} (HEAD)", branch));
}
}
if result.status == "deployed" {
succeeded += 1;
} else {
failed += 1;
}
results.push(result);
}
if !tag_checkouts.is_empty() {
restore_branches(&tag_checkouts);
}
Ok(DeployOrchestrationResult {
results,
summary: DeploySummary {
total: succeeded + failed,
succeeded,
failed,
skipped: 0,
},
})
}
fn run_check_mode(
components: &[Component],
local_versions: &HashMap<String, String>,
remote_versions: &HashMap<String, String>,
base_path: &str,
) -> DeployOrchestrationResult {
let results: Vec<ComponentDeployResult> = components
.iter()
.map(|c| {
let status = calculate_component_status(c, remote_versions);
let release_state = calculate_release_state(c);
let mut result = ComponentDeployResult::new(c, base_path)
.with_status("checked")
.with_versions(
local_versions.get(&c.id).cloned(),
remote_versions.get(&c.id).cloned(),
)
.with_component_status(status);
if let Some(state) = release_state {
result = result.with_release_state(state);
}
result
})
.collect();
let total = results.len() as u32;
DeployOrchestrationResult {
results,
summary: DeploySummary {
total,
succeeded: 0,
failed: 0,
skipped: 0,
},
}
}
fn run_dry_run_mode(
components: &[Component],
local_versions: &HashMap<String, String>,
remote_versions: &HashMap<String, String>,
base_path: &str,
config: &DeployConfig,
) -> DeployOrchestrationResult {
let results: Vec<ComponentDeployResult> = components
.iter()
.map(|c| {
let status = if config.check {
calculate_component_status(c, remote_versions)
} else {
ComponentStatus::Unknown
};
let mut result = ComponentDeployResult::new(c, base_path)
.with_status("planned")
.with_versions(
local_versions.get(&c.id).cloned(),
remote_versions.get(&c.id).cloned(),
);
if config.check {
result = result.with_component_status(status);
}
result
})
.collect();
let total = results.len() as u32;
DeployOrchestrationResult {
results,
summary: DeploySummary {
total,
succeeded: 0,
failed: 0,
skipped: 0,
},
}
}
fn check_uncommitted_changes(components: &[Component]) -> Result<()> {
let dirty: Vec<&str> = components
.iter()
.filter(|c| !git::is_workdir_clean(Path::new(&c.local_path)))
.map(|c| c.id.as_str())
.collect();
if !dirty.is_empty() {
return Err(Error::validation_invalid_argument(
"components",
format!("Components have uncommitted changes: {}", dirty.join(", ")),
None,
Some(vec![
"Commit your changes before deploying to ensure deployed code is tracked"
.to_string(),
"Use --force to deploy anyway".to_string(),
]),
));
}
Ok(())
}
fn sync_components(components: &[Component]) -> Result<()> {
for component in components {
let path = &component.local_path;
match git::fetch_and_get_behind_count(path) {
Ok(Some(behind)) => {
log_status!(
"deploy",
"'{}' is {} commit(s) behind remote — pulling...",
component.id,
behind
);
let pull_result = git::pull(Some(&component.id))?;
if !pull_result.success {
return Err(Error::git_command_failed(format!(
"Failed to pull '{}': {}",
component.id,
pull_result.stderr.lines().next().unwrap_or("unknown error")
)));
}
log_status!("deploy", "'{}' is now up to date", component.id);
}
Ok(None) => {
}
Err(_) => {
log_status!(
"deploy",
"Warning: could not check remote status for '{}' — deploying local state",
component.id
);
}
}
}
Ok(())
}
struct TagCheckout {
component_id: String,
tag: String,
original_ref: String,
local_path: String,
}
fn checkout_latest_tags(components: &[Component]) -> Result<Vec<TagCheckout>> {
let mut checkouts = Vec::new();
for component in components {
let path = &component.local_path;
let tag = match git::get_latest_tag(path) {
Ok(Some(t)) => t,
Ok(None) => {
log_status!(
"deploy",
"Warning: '{}' has no version tags — deploying from HEAD (use --head to suppress this warning)",
component.id
);
continue;
}
Err(_) => {
log_status!(
"deploy",
"Warning: could not read tags for '{}' — deploying from HEAD",
component.id
);
continue;
}
};
let original_ref = crate::utils::command::run_in_optional(
path,
"git",
&["rev-parse", "--abbrev-ref", "HEAD"],
)
.unwrap_or_else(|| "main".to_string());
let tag_commit = crate::utils::command::run_in_optional(path, "git", &["rev-parse", &tag]);
let head_commit = crate::utils::command::run_in_optional(path, "git", &["rev-parse", "HEAD"]);
if tag_commit.is_some() && tag_commit == head_commit {
log_status!("deploy", "'{}' is already at tag {} — no checkout needed", component.id, tag);
checkouts.push(TagCheckout {
component_id: component.id.clone(),
tag: tag.clone(),
original_ref,
local_path: path.clone(),
});
continue;
}
log_status!("deploy", "'{}' checking out tag {} for deploy...", component.id, tag);
match crate::utils::command::run_in(path, "git", &["checkout", &tag], "git checkout tag") {
Ok(_) => {
checkouts.push(TagCheckout {
component_id: component.id.clone(),
tag: tag.clone(),
original_ref,
local_path: path.clone(),
});
}
Err(e) => {
return Err(Error::git_command_failed(format!(
"Failed to checkout tag {} for '{}': {}",
tag, component.id, e
)));
}
}
}
Ok(checkouts)
}
fn restore_branches(checkouts: &[TagCheckout]) {
for checkout in checkouts {
if checkout.original_ref == "HEAD" {
continue;
}
let restore = crate::utils::command::run_in(
&checkout.local_path,
"git",
&["checkout", &checkout.original_ref],
"git checkout restore",
);
match restore {
Ok(_) => {
log_status!(
"deploy",
"'{}' restored to {}",
checkout.component_id,
checkout.original_ref
);
}
Err(e) => {
log_status!(
"deploy",
"Warning: could not restore '{}' to {}: {}",
checkout.component_id,
checkout.original_ref,
e
);
}
}
}
}
fn verify_expected_version(components: &[Component], expected: &str) -> Result<()> {
let mut mismatches = Vec::new();
for component in components {
if let Some(local_version) = version::get_component_version(component) {
if local_version != expected {
mismatches.push(format!(
"'{}': local version is {} (expected {})",
component.id, local_version, expected
));
}
}
}
if !mismatches.is_empty() {
return Err(Error::validation_invalid_argument(
"version",
format!("Version mismatch: {}", mismatches.join("; ")),
None,
Some(vec![
"Pull latest changes: git pull".to_string(),
"Or remove --version to deploy the current local version".to_string(),
]),
));
}
Ok(())
}