feat: deploy drift via auto-discovered VPS mirror, no manual metadata
Sprint E, redesigned from the original per-repo-metadata plan: the deploy host already mirrors workspace_root 1:1 (documented convention), so instead of maintaining a repo->host/compose-path mapping by hand, vulp refresh runs one batched remote command (via a configurable ssh wrapper) listing every mirrored repo's HEAD sha and diffs it against local HEAD. - src/deploy.rs: DeployState (InSync/Behind(n)/Diverged), scan_deploy_heads (one remote round-trip, retried once for this VPS's known SSH flakiness), drift_for (path relative to workspace_root -> host lookup -> local git rev-list --count) - config: deploy_ssh_cmd + deploy_root, both empty by default (feature is fully inert, no SSH attempted, until configured) - cache: RepoMeta.deploy_status, folded into the existing refresh loop (TUI [R] and CLI) alongside CI/dependabot rather than a second pass - table: DEPLOY column (checkmark/behind-count/diverged/dash); CLI list --stale-deploy and deploy_status in --json - verified against the real VPS: 37/49 repos matched to a mirror (30 in sync, 7 behind 1-4 commits - a real signal), everything else correctly shows unset (no mirror there, e.g. vulp itself, a pure OSS dev tool) - set deploy_ssh_cmd/deploy_root in the live config so this is active now, not just theoretically configurable
This commit is contained in:
1 parent
a3658d5e97
commit
4b0a6e324f
15 files changed
+256
-16
No files matched your search
@@ -146,6 +146,7 @@ pub fn poll_refresh(state: &mut HubState) {
|
||||
r.open_prs = meta.open_prs;
|
||||
r.ci_status = meta.ci_status.clone();
|
||||
r.dependabot_open = meta.dependabot_open;
|
||||
r.deploy_status = meta.deploy_status.clone();
|
||||
}
|
||||
cache.set_meta(&name, meta);
|
||||
fetched_meta += 1;
|
||||
|
||||
@@ -87,6 +87,7 @@ pub async fn run_overview_tui(
|
||||
r.open_prs = m.open_prs;
|
||||
r.ci_status = m.ci_status.clone();
|
||||
r.dependabot_open = m.dependabot_open;
|
||||
r.deploy_status = m.deploy_status.clone();
|
||||
if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
|
||||
}
|
||||
}
|
||||
|
||||
+18
-2
@@ -729,11 +729,12 @@ pub fn spawn_api_refresh(state: &mut HubState) {
|
||||
(r.name.clone(), r.owner.clone(), basename, r.branch.clone())
|
||||
})
|
||||
.collect();
|
||||
// For the Tier-B local measurements (release cadence, repo size) below —
|
||||
// those need the on-disk path, not the provider slug.
|
||||
// For the Tier-B local measurements (release cadence, repo size, deploy
|
||||
// drift) below — those need the on-disk path, not the provider slug.
|
||||
let repo_paths: Vec<(String, std::path::PathBuf)> = state.repos.iter()
|
||||
.map(|r| (r.name.clone(), r.path.clone()))
|
||||
.collect();
|
||||
let cfg = state.cfg.clone();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<RefreshMsg>();
|
||||
state.refresh_rx = Some(rx);
|
||||
@@ -778,6 +779,16 @@ pub fn spawn_api_refresh(state: &mut HubState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy drift: one batched remote command (if configured) lists
|
||||
// every mirrored repo's HEAD sha; comparing against local HEAD is
|
||||
// then a cheap per-repo git call, folded into the metas loop below.
|
||||
let deploy_heads = if crate::deploy::is_configured(&cfg) {
|
||||
let _ = tx.send(RefreshMsg::Progress("Checking deploy drift...".into()));
|
||||
crate::deploy::scan_deploy_heads(&cfg)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Up to 6 repos in flight — sequential fetch of ~50 repos × 4
|
||||
// requests took 40-80 s; this brings it to ~10 s without tripping
|
||||
// GitHub's secondary rate limits.
|
||||
@@ -806,6 +817,10 @@ pub fn spawn_api_refresh(state: &mut HubState) {
|
||||
.get(slug_lc.split('/').next().unwrap_or(""))
|
||||
.and_then(|per_org| per_org.as_ref()
|
||||
.map(|m| m.get(&slug_lc).copied().unwrap_or(0)));
|
||||
let deploy_status = deploy_heads.as_ref().and_then(|heads| {
|
||||
let path = &repo_paths.iter().find(|(n, _)| n == &name)?.1;
|
||||
crate::deploy::drift_for(path, &cfg.workspace_root, heads)
|
||||
}).map(|s| s.as_str());
|
||||
metas.push((name.clone(), crate::cache::RepoMeta {
|
||||
stars: Some(meta.stars),
|
||||
open_issues: Some(meta.open_issues),
|
||||
@@ -813,6 +828,7 @@ pub fn spawn_api_refresh(state: &mut HubState) {
|
||||
visibility: None,
|
||||
ci_status: ci.map(|c| c.as_str().to_string()),
|
||||
dependabot_open: dep,
|
||||
deploy_status,
|
||||
fetched_at: chrono::Utc::now().timestamp(),
|
||||
}));
|
||||
}
|
||||
|
||||
+5
-4
@@ -314,17 +314,18 @@ impl HubState {
|
||||
match result {
|
||||
Ok(mut repo) => {
|
||||
repo.bookmarked = self.bookmarks.contains(&repo.path);
|
||||
// Apply any cached API metadata (stars, PRs, issues, CI, alerts)
|
||||
// Apply any cached API metadata (stars, PRs, issues, CI, alerts, deploy)
|
||||
let cache_vals = self.meta_cache.get(&repo.name).map(|e| {
|
||||
(e.meta.stars, e.meta.open_issues, e.meta.open_prs,
|
||||
e.meta.visibility.clone(), e.meta.ci_status.clone(), e.meta.dependabot_open)
|
||||
(e.meta.stars, e.meta.open_issues, e.meta.open_prs, e.meta.visibility.clone(),
|
||||
e.meta.ci_status.clone(), e.meta.dependabot_open, e.meta.deploy_status.clone())
|
||||
});
|
||||
if let Some((stars, issues, prs, vis, ci, dep)) = cache_vals {
|
||||
if let Some((stars, issues, prs, vis, ci, dep, deploy)) = cache_vals {
|
||||
repo.stars = stars;
|
||||
repo.open_issues = issues;
|
||||
repo.open_prs = prs;
|
||||
repo.ci_status = ci;
|
||||
repo.dependabot_open = dep;
|
||||
repo.deploy_status = deploy;
|
||||
if repo.visibility.is_none() { repo.visibility = vis; }
|
||||
}
|
||||
self.repos.push(repo);
|
||||
|
||||
+10
-1
@@ -20,7 +20,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
let cur_di = state.cur_di().unwrap_or(usize::MAX);
|
||||
|
||||
let header = Row::new(vec![
|
||||
" ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "CI", "ALERT", "STARS",
|
||||
" ", "NAME", "BRANCH", "PROJECT", "VISIB", "AGE", "STATUS", "RELEASE", "LICENSE", "CI", "ALERT", "DEPLOY", "STARS",
|
||||
]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
|
||||
|
||||
let rows: Vec<Row> = state.display.iter().enumerate()
|
||||
@@ -66,6 +66,13 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
Some(0) | None => Color::DarkGray,
|
||||
Some(_) => Color::Red,
|
||||
};
|
||||
let deploy_state = r.deploy_status.as_deref().and_then(crate::deploy::DeployState::parse);
|
||||
let (deploy_str, deploy_col) = match deploy_state {
|
||||
Some(crate::deploy::DeployState::InSync) => ("✓".to_string(), Color::Green),
|
||||
Some(crate::deploy::DeployState::Behind(n)) => (format!("-{n}"), Color::Yellow),
|
||||
Some(crate::deploy::DeployState::Diverged) => ("≠".to_string(), Color::Red),
|
||||
None => ("—".to_string(), Color::DarkGray),
|
||||
};
|
||||
let stars_str = r.stars.map(|s| format!("{}", s)).unwrap_or_else(|| "—".into());
|
||||
let stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
|
||||
let is_pinned = state.pins.contains(&r.path);
|
||||
@@ -88,6 +95,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
Cell::from(lic_str).style(Style::default().fg(if r.has_license { Color::Green } else { Color::DarkGray })),
|
||||
Cell::from(ci_str).style(Style::default().fg(ci_col)),
|
||||
Cell::from(dep_str).style(Style::default().fg(dep_col)),
|
||||
Cell::from(deploy_str).style(Style::default().fg(deploy_col)),
|
||||
Cell::from(stars_str).style(Style::default().fg(stars_col)),
|
||||
]).style(row_sty))
|
||||
})
|
||||
@@ -105,6 +113,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(6),
|
||||
Constraint::Length(7),
|
||||
Constraint::Length(8),
|
||||
];
|
||||
f.render_widget(Table::new(rows, widths).header(header), area);
|
||||
|
||||
Reference in new issue
Block a user