From 4b0a6e324f1ddf46a6f886e263dbd05f556035a5 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 7 Jul 2026 18:22:40 +0200 Subject: [PATCH] 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 --- docs/cli.md | 33 ++++++++-- src/cache.rs | 4 ++ src/cli.rs | 2 + src/cli_cmds/mod.rs | 5 +- src/cli_cmds/remote.rs | 15 +++++ src/cli_cmds/workspace.rs | 20 +++++- src/config/mod.rs | 15 +++++ src/deploy.rs | 129 ++++++++++++++++++++++++++++++++++++++ src/hub/keys_poll.rs | 1 + src/hub/mod.rs | 1 + src/hub/modals_spawn.rs | 20 +++++- src/hub/state.rs | 9 +-- src/hub/tabs_tables.rs | 11 +++- src/main.rs | 1 + src/status/mod.rs | 6 +- 15 files changed, 256 insertions(+), 16 deletions(-) create mode 100644 src/deploy.rs diff --git a/docs/cli.md b/docs/cli.md index 3d6c8cc..25a6df5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -50,7 +50,7 @@ vulp [--json] # non-interactive (scripts/AI) ``` vulp scan # repo count + dirty/behind/ahead summary vulp brief [repo] # one-shot picture: state + commits + todos + PR/issues -vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] [--alerts] +vulp list [--dirty] [--behind] [--ahead] [--lang ts] [--project NAME] [--alerts] [--stale-deploy] vulp status # branch, dirty, ahead/behind, last commit... vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|commit|license] @@ -63,16 +63,41 @@ vulp activity [--limit N] [--kind push|pull|release|"new repo"|todo|pr|issue|com "last_commit_ts": 1780000000, "last_tag": null, "visibility": null, "owner": "dualfroz", "has_license": false, "lang": "TypeScript 96%", "bookmarked": false, "stars": null, "open_issues": null, "open_prs": null, - "stash_count": 0, "ci_status": "success", "dependabot_open": 0 }] + "stash_count": 0, "ci_status": "success", "dependabot_open": 0, + "deploy_status": "behind:3" }] ``` -`stars`/`open_issues`/`open_prs`/`ci_status`/`dependabot_open` come from the -on-disk cache (run `vulp refresh` first). `ci_status` is +`stars`/`open_issues`/`open_prs`/`ci_status`/`dependabot_open`/`deploy_status` +come from the on-disk cache (run `vulp refresh` first). `ci_status` is `"success" | "failure" | "running" | null`; `dependabot_open: null` means the alert endpoint isn't available for that repo's owner (personal account, missing token scope, or feature disabled) as opposed to `0` = "checked, none open". `--alerts` filters to repos with `dependabot_open > 0`. +`deploy_status` is `"in_sync" | "behind:N" | "diverged" | null` — see +**Deploy drift** below. `--stale-deploy` filters to repos that are `behind` +or `diverged` (excludes `in_sync` and `null`/not-applicable). + +## Deploy drift + +Auto-discovered, not manually configured: if `deploy_root` (config) mirrors +`workspace_root` 1:1 on a remote host (e.g. `/opt/projects/X/Y` on the host +↔ `{workspace_root}/X/Y` locally), and `deploy_ssh_cmd` (config) is a command +that runs its final argument as a shell command on that host and prints the +result (a wrapper around `ssh`), `vulp refresh` runs **one** batched remote +command listing every mirrored repo's HEAD sha, then compares each against +local HEAD. A repo with no mirror on the host (nothing to compare against — +e.g. a pure open-source component that never runs live anywhere) simply has +`deploy_status: null`, not an error. + +```toml +# ~/.vulp/config.toml +deploy_ssh_cmd = "python3 /path/to/vps_exec.py" # appends the remote command as its last arg +deploy_root = "/opt/projects" +``` + +Both empty (the default) disables the feature entirely — no SSH attempted. + ## Git operations ``` diff --git a/src/cache.rs b/src/cache.rs index da25595..57d07dc 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -21,6 +21,10 @@ pub struct RepoMeta { pub ci_status: Option, /// Open Dependabot alerts; None = endpoint unavailable for this repo. pub dependabot_open: Option, + /// Deploy-drift state (`DeployState::as_str`); None = not deployed + /// anywhere the mirror convention can see, or feature not configured. + #[serde(default)] + pub deploy_status: Option, pub fetched_at: i64, } diff --git a/src/cli.rs b/src/cli.rs index 6c515c2..5badef4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -49,6 +49,8 @@ pub enum Commands { #[arg(long)] project: Option, /// Only repos with open Dependabot alerts (cached — run refresh first) #[arg(long)] alerts: bool, + /// Only repos whose deploy host is behind or diverged (needs deploy_ssh_cmd/deploy_root configured) + #[arg(long)] stale_deploy: bool, }, /// Detailed status of one repo Status { repo: String }, diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index 26dbcd9..79245e9 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -27,8 +27,8 @@ pub(crate) struct Guard { /// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`). pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> { match cmd { - Commands::List { dirty, behind, ahead, lang, project, alerts } => - workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts), + Commands::List { dirty, behind, ahead, lang, project, alerts, stale_deploy } => + workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts, stale_deploy), Commands::Status { repo } => workspace::status(cfg, json, &repo), Commands::Scan => workspace::scan_cmd(cfg, json), Commands::Sync { repos, all, project, action, message, allow_secrets } => @@ -88,6 +88,7 @@ pub(crate) fn scan_repos(cfg: &AppConfig) -> Vec { 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(); } } } diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs index cfb182c..29c4b71 100644 --- a/src/cli_cmds/remote.rs +++ b/src/cli_cmds/remote.rs @@ -339,6 +339,16 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { } } + // 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) { + eprintln!("checking deploy drift..."); + crate::deploy::scan_deploy_heads(cfg) + } else { + None + }; + let results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug, branch)| { let provider = provider.clone(); async move { @@ -362,6 +372,10 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { .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 = &repos.iter().find(|r| r.name == name)?.path; + crate::deploy::drift_for(path, &cfg.workspace_root, heads) + }).map(|s| s.as_str()); cache.set_meta(&name, crate::cache::RepoMeta { stars: Some(meta.stars), open_issues: Some(meta.open_issues), @@ -369,6 +383,7 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> { visibility: None, ci_status: ci.map(|c| c.as_str().to_string()), dependabot_open: dep, + deploy_status, fetched_at: chrono::Utc::now().timestamp(), }); fetched_meta += 1; diff --git a/src/cli_cmds/workspace.rs b/src/cli_cmds/workspace.rs index 2a3699c..78911d9 100644 --- a/src/cli_cmds/workspace.rs +++ b/src/cli_cmds/workspace.rs @@ -9,7 +9,7 @@ use anyhow::Result; pub fn list( cfg: &AppConfig, json: bool, dirty: bool, behind: bool, ahead: bool, - lang: Option, project: Option, alerts: bool, + lang: Option, project: Option, alerts: bool, stale_deploy: bool, ) -> Result<()> { let mut repos = scan_repos(cfg); @@ -17,6 +17,14 @@ pub fn list( if behind { repos.retain(|r| r.behind > 0); } if ahead { repos.retain(|r| r.ahead > 0); } if alerts { repos.retain(|r| r.dependabot_open.unwrap_or(0) > 0); } + if stale_deploy { + repos.retain(|r| { + !matches!( + r.deploy_status.as_deref().and_then(crate::deploy::DeployState::parse), + None | Some(crate::deploy::DeployState::InSync) + ) + }); + } if let Some(l) = lang { let l = l.to_lowercase(); repos.retain(|r| r.lang.as_deref().map(|x| x.to_lowercase().contains(&l)).unwrap_or(false)); @@ -45,7 +53,15 @@ pub fn list( if r.is_dirty { flags.push('⚠'); } if r.behind > 0 { flags.push('↓'); } if r.ahead > 0 { flags.push('↑'); } - if alerts { + if stale_deploy { + let state = r.deploy_status.as_deref().and_then(crate::deploy::DeployState::parse); + let label = match state { + Some(crate::deploy::DeployState::Behind(n)) => format!("{n} behind"), + Some(crate::deploy::DeployState::Diverged) => "diverged".into(), + _ => "-".into(), + }; + println!("{:3} alerts {}", r.name, r.branch, r.dependabot_open.unwrap_or(0), diff --git a/src/config/mod.rs b/src/config/mod.rs index 8700fa3..17e4c86 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -53,6 +53,15 @@ pub struct AppConfig { /// no-reply address for each configured account is always matched too. #[serde(default)] pub stats_author_emails: Vec, + /// Command (program + args, split on whitespace) that runs its final + /// argument as a shell command on the deploy host and prints stdout — + /// e.g. a wrapper around `ssh`. Empty = deploy-drift feature disabled. + #[serde(default)] + pub deploy_ssh_cmd: String, + /// Root on the deploy host that mirrors `workspace_root` 1:1 (relative + /// paths past these two roots are identical). Empty = feature disabled. + #[serde(default)] + pub deploy_root: String, } fn default_license_kind() -> String { "mit".into() } @@ -93,6 +102,8 @@ impl Default for AppConfig { default_license: default_license_kind(), protected_repos: vec![], stats_author_emails: vec![], + deploy_ssh_cmd: String::new(), + deploy_root: String::new(), } } } @@ -120,6 +131,8 @@ impl AppConfig { ("default_license", self.default_license.clone()), ("protected_repos", self.protected_repos.join(", ")), ("stats_author_emails", self.stats_author_emails.join(", ")), + ("deploy_ssh_cmd", self.deploy_ssh_cmd.clone()), + ("deploy_root", self.deploy_root.clone()), ] } @@ -157,6 +170,8 @@ impl AppConfig { .filter(|s| !s.is_empty()) .collect(); } + "deploy_ssh_cmd" => self.deploy_ssh_cmd = value, + "deploy_root" => self.deploy_root = value, "workspace_groups" => { self.workspace_groups = value .split(',') diff --git a/src/deploy.rs b/src/deploy.rs new file mode 100644 index 0000000..bd2d419 --- /dev/null +++ b/src/deploy.rs @@ -0,0 +1,129 @@ +//! Deploy drift: is what's on the deploy host actually what's in the repo? +//! Auto-discovered from a 1:1 mirror convention (`deploy_root` on the host +//! mirrors `workspace_root` locally past those two roots) rather than +//! per-repo metadata the user would have to maintain by hand — one batched +//! remote command lists every mirrored repo's HEAD sha, and repos with no +//! mirror (never deployed there — e.g. this very tool, which is a pure +//! open-source dev CLI) simply have no entry, not an error. + +use crate::config::AppConfig; +use std::collections::HashMap; +use std::path::Path; + +/// One repo's drift relative to the deploy host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeployState { + InSync, + Behind(u32), + /// The deployed sha isn't reachable from local HEAD — a rebase, a + /// force-push, or (as happened to this very repo) a history rewrite. + Diverged, +} + +impl DeployState { + /// Stable string form for the on-disk cache and `--json` output. + pub fn as_str(&self) -> String { + match self { + Self::InSync => "in_sync".into(), + Self::Behind(n) => format!("behind:{n}"), + Self::Diverged => "diverged".into(), + } + } + + pub fn parse(s: &str) -> Option { + if s == "in_sync" { return Some(Self::InSync); } + if s == "diverged" { return Some(Self::Diverged); } + s.strip_prefix("behind:")?.parse().ok().map(Self::Behind) + } +} + +/// Whether the feature is configured at all (`deploy_ssh_cmd` + `deploy_root` +/// both set). Callers should skip the SSH round-trip entirely when this is +/// false rather than attempting it and failing. +pub fn is_configured(cfg: &AppConfig) -> bool { + !cfg.deploy_ssh_cmd.trim().is_empty() && !cfg.deploy_root.trim().is_empty() +} + +/// Runs `deploy_ssh_cmd` once to list every git repo under `deploy_root` on +/// the deploy host with its current HEAD sha, keyed by path relative to +/// `deploy_root`. One round-trip covers every repo — the whole point of the +/// mirror convention is never needing a request per repo. `None` when the +/// feature isn't configured or the command fails outright (transient SSH +/// drops are retried once, per the known flakiness of this specific setup). +pub fn scan_deploy_heads(cfg: &AppConfig) -> Option> { + if !is_configured(cfg) { return None; } + let root = cfg.deploy_root.trim().trim_end_matches('/'); + let remote_script = format!( + "find {root} -maxdepth 10 -type d -name .git 2>/dev/null | while IFS= read -r g; do \ + d=$(dirname \"$g\"); sha=$(git -C \"$d\" rev-parse HEAD 2>/dev/null); \ + printf '%s\\x1f%s\\n' \"$d\" \"$sha\"; done" + ); + + let mut parts = cfg.deploy_ssh_cmd.split_whitespace(); + let program = parts.next()?; + let base_args: Vec<&str> = parts.collect(); + + let run = || -> Option { + let out = std::process::Command::new(program) + .args(&base_args) + .arg(&remote_script) + .output() + .ok()?; + if !out.status.success() { return None; } + Some(String::from_utf8_lossy(&out.stdout).into_owned()) + }; + // One retry — this VPS's SSH is known to drop intermittently. + let output = run().or_else(run)?; + + let mut heads = HashMap::new(); + for line in output.lines() { + let Some((path, sha)) = line.split_once('\u{1f}') else { continue }; + let sha = sha.trim(); + if sha.is_empty() { continue; } + let Some(rel) = path.strip_prefix(root) else { continue }; + heads.insert(rel.trim_start_matches('/').to_string(), sha.to_string()); + } + Some(heads) +} + +/// The local repo's own HEAD sha. +fn local_head_sha(path: &Path) -> Option { + let out = std::process::Command::new("git") + .arg("-C").arg(path).args(["rev-parse", "HEAD"]) + .output().ok()?; + if !out.status.success() { return None; } + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if sha.is_empty() { None } else { Some(sha) } +} + +/// How many commits `deployed_sha` is behind local HEAD, or `None` if +/// `deployed_sha` isn't an ancestor reachable from HEAD at all (rebase, +/// force-push, or a history rewrite like this repo's own `filter-repo` run — +/// in that case there's no meaningful count, only "diverged"). +fn commits_behind(path: &Path, deployed_sha: &str) -> Option { + let out = std::process::Command::new("git") + .arg("-C").arg(path) + .args(["rev-list", "--count", &format!("{deployed_sha}..HEAD")]) + .output().ok()?; + if !out.status.success() { return None; } + String::from_utf8_lossy(&out.stdout).trim().parse().ok() +} + +/// Computes drift for one repo. `None` = no mirror found on the deploy host +/// (not applicable — e.g. a pure open-source component that never runs +/// live) or the feature isn't configured; distinguishing "not deployed" +/// from "checked, in sync" is the whole reason this returns `Option`. +pub fn drift_for( + repo_path: &Path, workspace_root: &Path, deploy_heads: &HashMap, +) -> Option { + let rel = repo_path.strip_prefix(workspace_root).ok()?; + let deployed_sha = deploy_heads.get(&rel.to_string_lossy().replace('\\', "/"))?; + let local_sha = local_head_sha(repo_path)?; + if &local_sha == deployed_sha { + return Some(DeployState::InSync); + } + match commits_behind(repo_path, deployed_sha) { + Some(n) => Some(DeployState::Behind(n)), + None => Some(DeployState::Diverged), + } +} diff --git a/src/hub/keys_poll.rs b/src/hub/keys_poll.rs index ca36e59..be0879e 100644 --- a/src/hub/keys_poll.rs +++ b/src/hub/keys_poll.rs @@ -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; diff --git a/src/hub/mod.rs b/src/hub/mod.rs index e731ab8..af086ef 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -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(); } } } diff --git a/src/hub/modals_spawn.rs b/src/hub/modals_spawn.rs index dcc4ead..b76e335 100644 --- a/src/hub/modals_spawn.rs +++ b/src/hub/modals_spawn.rs @@ -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::(); 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(), })); } diff --git a/src/hub/state.rs b/src/hub/state.rs index 5316c81..11dfd3b 100644 --- a/src/hub/state.rs +++ b/src/hub/state.rs @@ -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); diff --git a/src/hub/tabs_tables.rs b/src/hub/tabs_tables.rs index 3ac2d85..5660019 100644 --- a/src/hub/tabs_tables.rs +++ b/src/hub/tabs_tables.rs @@ -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 = 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); diff --git a/src/main.rs b/src/main.rs index 5a35f6b..0c60c88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod cli_cmds; mod codeintel; mod config; mod debug; +mod deploy; mod detail; mod digest; mod hub; diff --git a/src/status/mod.rs b/src/status/mod.rs index 668d6e7..9e7eb48 100644 --- a/src/status/mod.rs +++ b/src/status/mod.rs @@ -43,6 +43,10 @@ pub struct RepoSummary { pub ci_status: Option, /// Open Dependabot alerts (from the meta cache / API refresh). pub dependabot_open: Option, + /// Deploy-drift state string (`deploy::DeployState::as_str`), from the + /// meta cache / `[R]` refresh; None = not deployed anywhere the mirror + /// convention can see, or the feature isn't configured. + pub deploy_status: Option, } /// One commit's display fields for a recent-commits list. @@ -398,7 +402,7 @@ pub fn repo_summary(path: &Path) -> Result { last_tag, visibility: None, owner, has_license, lang, bookmarked: false, stars: None, open_issues: None, open_prs: None, stash_count, - ci_status: None, dependabot_open: None, + ci_status: None, dependabot_open: None, deploy_status: None, }) }