use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use crate::cli::GitRefreshArgs;
use crate::constants::THESA_METADATA_DIR;
use crate::error::{Result, ThesaError};
use crate::manifest::{read_archive_manifest_sidecar, write_archive_manifest};
pub(crate) fn run_git_archive_refresh(args: &GitRefreshArgs, dry_run: bool) -> Result<()> {
let owner = git_refresh_owner(args)?;
let archives = if let Some(owner) = owner.as_deref() {
repoforge::discover_git_archives_for_owner(
&args.archive_root,
owner,
args.filter.as_deref(),
)
.map_err(repoforge_err)?
} else {
repoforge::discover_git_archives(&args.archive_root, args.filter.as_deref())
.map_err(repoforge_err)?
};
if archives.is_empty() {
if let Some(owner) = owner.as_deref() {
println!(
"No Git archives found for owner {owner} at {}",
args.archive_root.display()
);
} else {
println!("No Git archives found at {}", args.archive_root.display());
}
return Ok(());
}
if dry_run {
println!(
"Dry run: {} Git archives would be refreshed",
archives.len()
);
println!("Archive root: {}", args.archive_root.display());
if let Some(owner) = owner.as_deref() {
println!("Owner: {owner}");
}
if let Some(filter) = &args.filter {
println!("Filter: {filter}");
}
for archive in &archives {
let remote_note = if archive.remote_url.is_some() {
"origin"
} else {
"slug inferred from path"
};
println!(
" - {} ({}) [{}]",
archive.slug,
archive.path.display(),
remote_note
);
}
return Ok(());
}
println!(
"Refreshing {} Git archives under {}",
archives.len(),
args.archive_root.display()
);
if let Some(owner) = owner.as_deref() {
println!("Owner: {owner}");
}
let summary = repoforge::refresh_git_archives(&archives, args.concurrency.max(1));
let manifests =
write_git_refresh_manifests(&args.archive_root, &archives, &summary.failed_slugs())?;
let total = archives.len();
for (index, entry) in summary.entries.iter().enumerate() {
match entry.status {
repoforge::GitRefreshStatus::Updated => println!(
"[done] {} updated ({}/{}) {}",
entry.slug,
index + 1,
total,
entry.path.display()
),
repoforge::GitRefreshStatus::Unchanged => println!(
"[skip] {} up-to-date ({}/{}) {}",
entry.slug,
index + 1,
total,
entry.path.display()
),
}
}
for failure in &summary.failed {
eprintln!(
"[fail] {} {}: {}",
failure.slug,
failure.path.display(),
failure.message
);
}
println!(
"\nCompleted: {} updated, {} up-to-date, {} failed",
summary.updated,
summary.unchanged,
summary.failed.len()
);
if !manifests.is_empty() {
println!("Manifests refreshed:");
for manifest in &manifests {
println!(" - {}", manifest.display());
}
}
if !summary.failed.is_empty() {
eprintln!("Failures:");
for failure in &summary.failed {
eprintln!(" - {}: {}", failure.slug, failure.message);
}
return Err(ThesaError::Message(
"some Git archives failed to refresh".to_string(),
));
}
Ok(())
}
pub(crate) fn git_refresh_owner(args: &GitRefreshArgs) -> Result<Option<String>> {
let positional = args
.owner
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let flag = args
.owner_filter
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
match (positional, flag) {
(Some(left), Some(right)) if !left.eq_ignore_ascii_case(right) => Err(ThesaError::Message(
format!("conflicting refresh owners: positional '{left}' and --owner '{right}'"),
)),
(Some(owner), _) | (_, Some(owner)) => Ok(Some(owner.to_string())),
(None, None) => Ok(None),
}
}
fn repoforge_err(err: repoforge::RepoForgeError) -> ThesaError {
ThesaError::Message(format!("repoforge refresh failed: {err}"))
}
fn write_git_refresh_manifests(
root: &Path,
archives: &[repoforge::GitArchiveCandidate],
failed_slugs: &BTreeSet<String>,
) -> Result<Vec<PathBuf>> {
let mut groups: BTreeMap<PathBuf, Vec<repoforge::GitArchiveCandidate>> = BTreeMap::new();
for archive in archives {
groups
.entry(git_archive_group_root(root, &archive.path))
.or_default()
.push(archive.clone());
}
let mut manifests = Vec::new();
for (group_root, group_archives) in groups {
let target = git_refresh_manifest_target(root, &group_root, &group_archives);
let complete = group_archives
.iter()
.all(|archive| !failed_slugs.contains(&archive.slug));
write_archive_manifest(&group_root, "github", &target, complete)?;
manifests.push(group_root.join(THESA_METADATA_DIR).join("manifest.json"));
}
Ok(manifests)
}
fn git_archive_group_root(root: &Path, repo_path: &Path) -> PathBuf {
if let Ok(relative) = repo_path.strip_prefix(root) {
for component in relative.components() {
if let Component::Normal(first) = component {
return root.join(first);
}
}
}
repo_path.to_path_buf()
}
fn git_refresh_manifest_target(
root: &Path,
group_root: &Path,
archives: &[repoforge::GitArchiveCandidate],
) -> String {
if let Ok(manifest) = read_archive_manifest_sidecar(group_root) {
if manifest.source.platform == "github" && !manifest.source.target.trim().is_empty() {
return manifest.source.target;
}
}
let group_name = group_root
.strip_prefix(root)
.ok()
.and_then(|relative| relative.components().next())
.and_then(|component| match component {
Component::Normal(value) => value.to_str().map(ToString::to_string),
_ => None,
})
.or_else(|| {
group_root
.file_name()
.and_then(|value| value.to_str())
.map(ToString::to_string)
})
.unwrap_or_else(|| "github".to_string());
let owners = archives
.iter()
.filter_map(|archive| {
archive
.slug
.split_once('/')
.map(|(owner, _)| owner.to_string())
})
.collect::<BTreeSet<_>>();
if owners.len() == 1 && owners.contains(&group_name) {
return group_name;
}
if archives.len() == 1 {
return archives[0].slug.clone();
}
group_name
}