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

+10 -1
View File
@@ -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);