use crate::cache::CiCache;
use crate::commands::ci::{BranchCiStatus, fetch_ci_statuses, update_ci_cache};
use crate::config::Config;
use crate::engine::Stack;
use crate::forge::ForgeClient;
use crate::git::GitRepo;
use crate::remote::{self, RemoteInfo};
use anyhow::Result;
use chrono::Local;
use colored::Colorize;
use std::collections::HashSet;
use std::io::Write as _;
use std::time::Duration;
const DEFAULT_INTERVAL_ACTIVE: u64 = 15;
const DEFAULT_INTERVAL_IDLE: u64 = 60;
const DEFAULT_INTERVAL_QUIET: u64 = 120;
pub fn run(current_only: bool, interval: Option<u64>) -> Result<()> {
let repo = GitRepo::open()?;
let config = Config::load()?;
let remote_info = RemoteInfo::from_repo(&repo, &config)?;
let rt = tokio::runtime::Runtime::new()?;
let _enter = rt.enter();
let client = ForgeClient::new(&remote_info)?;
let mut iteration = 0usize;
loop {
let stack = Stack::load(&repo)?;
let current = repo.current_branch()?;
let cache_dir = repo.common_git_dir()?;
let workdir = repo.workdir()?;
let branches_to_watch: Vec<String> = if current_only {
stack
.current_stack(¤t)
.into_iter()
.filter(|b| b != &stack.trunk)
.collect()
} else {
let mut branches: Vec<String> = stack
.branches
.keys()
.filter(|b| *b != &stack.trunk)
.cloned()
.collect();
branches.sort();
branches
};
let ci_statuses: Vec<BranchCiStatus> = if !branches_to_watch.is_empty() {
match fetch_ci_statuses(&repo, &rt, &client, &stack, &branches_to_watch) {
Ok(s) => {
update_ci_cache(&repo, &stack, &s);
s
}
Err(_) => {
load_ci_from_cache(&repo, &cache_dir, &branches_to_watch)
}
}
} else {
vec![]
};
let remote_branches: HashSet<String> =
remote::get_remote_branches(workdir, config.remote_name())
.unwrap_or_default()
.into_iter()
.collect();
iteration += 1;
if iteration > 1 {
print!("\x1B[2J\x1B[H");
let _ = std::io::stdout().flush();
}
let now = Local::now().format("%H:%M:%S");
println!(
"{}{}",
"⟳ Watching stack (Ctrl+C to stop)".cyan().bold(),
format!(" {}", now).dimmed(),
);
println!();
if branches_to_watch.is_empty() {
println!("{}", "No tracked branches.".dimmed());
} else {
render_watch_table(
&stack,
¤t,
&branches_to_watch,
&ci_statuses,
&remote_branches,
);
}
let next_interval =
interval.unwrap_or_else(|| adaptive_interval(&ci_statuses, &stack, &branches_to_watch));
println!();
println!(
"{}",
format!(
"Refreshing in {}s… (iteration #{})",
next_interval, iteration
)
.dimmed()
);
std::thread::sleep(Duration::from_secs(next_interval));
}
}
fn render_watch_table(
stack: &Stack,
current: &str,
branches: &[String],
ci_statuses: &[BranchCiStatus],
remote_branches: &HashSet<String>,
) {
let ci_by_branch: std::collections::HashMap<&str, &BranchCiStatus> =
ci_statuses.iter().map(|s| (s.branch.as_str(), s)).collect();
let name_width = branches.iter().map(|b| b.len()).max().unwrap_or(10).max(10);
for branch in branches {
let info = stack.branches.get(branch);
let is_current = branch == current;
let marker = if is_current {
"◉".cyan().bold().to_string()
} else {
"○".dimmed().to_string()
};
let cloud = if remote_branches.contains(branch) || info.and_then(|b| b.pr_number).is_some()
{
"☁".bright_blue().to_string()
} else {
" ".to_string()
};
let branch_display = if is_current {
format!("{:<width$}", branch, width = name_width)
.bold()
.to_string()
} else {
format!("{:<width$}", branch, width = name_width)
};
let ci_tag = match ci_by_branch.get(branch.as_str()) {
None => " – ".dimmed().to_string(),
Some(s) if s.check_runs.is_empty() => " – ".dimmed().to_string(),
Some(s) => match s.overall_status.as_deref() {
Some("success") => " ✓ passed ".green().to_string(),
Some("failure") => " ✗ failed ".red().bold().to_string(),
Some("pending") => " ● running… ".yellow().to_string(),
_ => " ○ unknown ".dimmed().to_string(),
},
};
let pr_tag = match info.and_then(|b| b.pr_number) {
Some(n) => {
let state = info.and_then(|b| b.pr_state.as_deref()).unwrap_or("open");
let ci_status = ci_by_branch.get(branch.as_str());
let draft = ci_status
.and_then(|s| s.pr_is_draft)
.or_else(|| info.and_then(|b| b.pr_is_draft))
.unwrap_or(false);
let review_decision = ci_status.and_then(|s| s.pr_review_decision.as_deref());
if state.to_lowercase() == "merged" {
format!(" PR #{} merged", n).bright_magenta().to_string()
} else if draft {
format!(" PR #{} draft", n).dimmed().to_string()
} else {
match review_decision {
Some("APPROVED") => {
format!(" PR #{} approved", n).green().bold().to_string()
}
Some("CHANGES_REQUESTED") => {
format!(" PR #{} changes", n).red().bold().to_string()
}
Some("REVIEW_REQUIRED") => {
format!(" PR #{} review", n).yellow().to_string()
}
_ => format!(" PR #{}", n).bright_magenta().to_string(),
}
}
}
None => String::new(),
};
println!(
" {} {} {} {}{}",
marker, cloud, branch_display, ci_tag, pr_tag
);
}
let trunk = &stack.trunk;
let is_trunk_current = trunk == current;
let trunk_marker = if is_trunk_current {
"◉".cyan().bold().to_string()
} else {
"○".dimmed().to_string()
};
let trunk_cloud = if remote_branches.contains(trunk) {
"☁".bright_blue().to_string()
} else {
" ".to_string()
};
println!(" {} {} {}", trunk_marker, trunk_cloud, trunk.dimmed());
}
fn load_ci_from_cache(
repo: &GitRepo,
cache_dir: &std::path::Path,
branches: &[String],
) -> Vec<BranchCiStatus> {
let cache = CiCache::load(cache_dir);
branches
.iter()
.filter_map(|branch| {
let revision = repo.branch_commit(branch).ok()?;
cache
.get_ci_state_for_revision(branch, &revision)
.map(|overall_status| {
let pr_is_draft = cache
.branches
.get(branch.as_str())
.and_then(|e| e.pr_state.as_deref())
.map(|s| s.eq_ignore_ascii_case("draft"));
BranchCiStatus {
branch: branch.clone(),
sha_short: revision.chars().take(7).collect(),
sha: revision,
overall_status: Some(overall_status),
check_runs: vec![],
pr_number: None,
pr_is_draft,
pr_title: None,
pr_review_decision: None,
}
})
})
.collect()
}
fn adaptive_interval(ci_statuses: &[BranchCiStatus], stack: &Stack, branches: &[String]) -> u64 {
let any_running = ci_statuses.iter().any(|s| {
s.check_runs.iter().any(|c| {
matches!(
c.status.as_str(),
"in_progress" | "queued" | "waiting" | "requested" | "pending"
)
})
});
if any_running {
return DEFAULT_INTERVAL_ACTIVE;
}
let any_open_prs = branches.iter().any(|b| {
stack
.branches
.get(b)
.and_then(|br| br.pr_state.as_deref())
.map(|s| s.to_lowercase() == "open")
.unwrap_or(false)
});
if any_open_prs {
return DEFAULT_INTERVAL_IDLE;
}
DEFAULT_INTERVAL_QUIET
}