use tga::collect::collector::FetchOutcome;
use tga::collect::CollectionPipeline;
use tga::core::config::Config;
use tga::core::db::Database;
use crate::commands::date_range::resolve_date_range;
use crate::CollectArgs;
pub async fn run(config: Config, db: &mut Database, args: CollectArgs) -> anyhow::Result<()> {
let mut cfg = config;
if !args.repos.is_empty() {
cfg.repositories.retain(|r| {
let name = r.name.clone().unwrap_or_default();
args.repos.contains(&name)
});
if cfg.repositories.is_empty() {
tracing::warn!(
"no repositories matched --repos filter ({:?}); nothing to do",
args.repos
);
}
}
let legacy_since = args.since.clone();
let (resolved_since, resolved_until) = resolve_date_range(
args.weeks,
args.from.as_deref(),
args.to.as_deref(),
legacy_since.as_deref(),
)?;
let effective_until = resolved_until.or_else(|| args.until.clone());
if let Some(since) = resolved_since.as_ref() {
tracing::info!(since = %since, "applying collection lower bound");
for repo in &mut cfg.repositories {
repo.since_date = Some(since.clone());
}
}
if let Some(until) = effective_until.as_ref() {
tracing::info!(until = %until, "applying collection upper bound");
for repo in &mut cfg.repositories {
repo.until_date = Some(until.clone());
}
}
if args.no_fetch {
eprintln!(
"WARNING: --no-fetch active. Local clones may be stale. \
tga collect will walk only what's already in your local object store."
);
}
let pipeline = CollectionPipeline::new(cfg)
.with_force(args.force)
.with_no_fetch(args.no_fetch)
.with_force_refresh_prs(args.force_refresh_prs)
.with_skip_tag_reachability(args.skip_tag_reachability)
.with_head_only(args.head_only)
.with_branches(args.branch)
.with_strict_fetch(args.strict_fetch)
.with_verbose_fetch(args.verbose_fetch);
let stats = if args.dry_run {
tracing::info!("Dry run — no database writes will occur");
let mut shadow = Database::open_in_memory()?;
pipeline.run(&mut shadow).await?
} else {
pipeline.run(db).await?
};
if args.dry_run {
println!(
"Dry run complete. Would have written {} commits, {} authors, \
{} PRs ({} weeks collected, {} weeks skipped). No changes persisted.",
stats.commits_collected,
stats.authors_resolved,
stats.prs_fetched,
stats.weeks_collected,
stats.weeks_skipped,
);
} else {
println!(
"Collected {} commits from {} authors ({} PRs fetched, \
{} weeks collected, {} weeks skipped)",
stats.commits_collected,
stats.authors_resolved,
stats.prs_fetched,
stats.weeks_collected,
stats.weeks_skipped,
);
}
if !stats.errors.is_empty() {
eprintln!(
"Encountered {} warnings during collection:",
stats.errors.len()
);
for e in &stats.errors {
eprintln!(" warning: {e}");
}
}
print_fetch_summary(&stats.fetch_outcomes, args.verbose_fetch);
if args.strict_fetch {
let failures: Vec<_> = stats
.fetch_outcomes
.iter()
.filter(|f| matches!(f.outcome, FetchOutcome::Failed { .. }))
.collect();
if !failures.is_empty() {
anyhow::bail!(
"--strict-fetch: {} repo(s) had fetch failures (see fetch summary above)",
failures.len()
);
}
}
Ok(())
}
fn print_fetch_summary(outcomes: &[tga::collect::collector::PerRepoFetch], verbose: bool) {
if outcomes.is_empty() {
return;
}
let total = outcomes.len();
let successes: Vec<_> = outcomes
.iter()
.filter(|f| matches!(f.outcome, FetchOutcome::Success { .. }))
.collect();
let failures: Vec<_> = outcomes
.iter()
.filter(|f| matches!(f.outcome, FetchOutcome::Failed { .. }))
.collect();
let skipped: Vec<_> = outcomes
.iter()
.filter(|f| matches!(f.outcome, FetchOutcome::Skipped { .. }))
.collect();
let fetched = successes.len();
let failed = failures.len();
if failed > 0 {
eprintln!(
"Fetch summary: {fetched} / {total} repos updated ({failed} failure(s), {} skipped)",
skipped.len()
);
for f in &failures {
if let FetchOutcome::Failed { error, .. } = &f.outcome {
eprintln!(" - {}: {error}", f.repo);
}
}
} else {
eprintln!(
"Fetch summary: {fetched} / {total} repos updated (0 failures, {} skipped)",
skipped.len()
);
}
if verbose {
for f in &successes {
if let FetchOutcome::Success { remote } = &f.outcome {
eprintln!(" + {}: fetched from {remote}", f.repo);
}
}
for f in &skipped {
if let FetchOutcome::Skipped { reason } = &f.outcome {
eprintln!(" ~ {}: skipped ({reason})", f.repo);
}
}
}
}
#[cfg(test)]
mod tests {
use tga::collect::collector::{FetchOutcome, PerRepoFetch};
use super::*;
#[test]
fn fetch_summary_counts_correctly() {
let outcomes = vec![
PerRepoFetch {
repo: "repo-a".to_string(),
outcome: FetchOutcome::Success {
remote: "origin".to_string(),
},
},
PerRepoFetch {
repo: "repo-b".to_string(),
outcome: FetchOutcome::Failed {
remote: "origin".to_string(),
error: "timeout".to_string(),
},
},
PerRepoFetch {
repo: "repo-c".to_string(),
outcome: FetchOutcome::Skipped {
reason: "--no-fetch".to_string(),
},
},
];
print_fetch_summary(&outcomes, false);
print_fetch_summary(&outcomes, true);
}
#[test]
fn fetch_summary_empty_outcomes_is_noop() {
print_fetch_summary(&[], false);
}
#[test]
fn pipeline_builder_accepts_fetch_flags() {
use tga::collect::CollectionPipeline;
use tga::core::config::Config;
let cfg = Config::default();
let pipeline = CollectionPipeline::new(cfg)
.with_strict_fetch(true)
.with_verbose_fetch(true);
assert!(pipeline.strict_fetch());
assert!(pipeline.verbose_fetch());
}
#[test]
fn no_fetch_composes_with_strict_and_verbose_fetch() {
use tga::collect::CollectionPipeline;
use tga::core::config::Config;
let cfg = Config::default();
let pipeline = CollectionPipeline::new(cfg)
.with_no_fetch(true)
.with_strict_fetch(true)
.with_verbose_fetch(true);
assert!(pipeline.strict_fetch(), "strict_fetch must be true");
assert!(pipeline.verbose_fetch(), "verbose_fetch must be true");
}
}