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:
dualfroz committed 2026-07-07 18:55:13 +02:00
1 parent a3658d5e97
commit 4b0a6e324f
15 files changed
+256 -16

No files matched your search

+18 -2
View File
@@ -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(),
}));
}