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

+29 -4
View File
@@ -50,7 +50,7 @@ vulp <command> [--json] # non-interactive (scripts/AI)
``` ```
vulp scan # repo count + dirty/behind/ahead summary vulp scan # repo count + dirty/behind/ahead summary
vulp brief [repo] # one-shot picture: state + commits + todos + PR/issues 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 <repo> # branch, dirty, ahead/behind, last commit... vulp status <repo> # branch, dirty, ahead/behind, last commit...
vulp stats [--period 1d|7d|30d|90d|1y|all] # commits per repo in period 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] 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, "last_commit_ts": 1780000000, "last_tag": null, "visibility": null,
"owner": "dualfroz", "has_license": false, "lang": "TypeScript 96%", "owner": "dualfroz", "has_license": false, "lang": "TypeScript 96%",
"bookmarked": false, "stars": null, "open_issues": null, "open_prs": null, "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 `stars`/`open_issues`/`open_prs`/`ci_status`/`dependabot_open`/`deploy_status`
on-disk cache (run `vulp refresh` first). `ci_status` is come from the on-disk cache (run `vulp refresh` first). `ci_status` is
`"success" | "failure" | "running" | null`; `dependabot_open: null` means the `"success" | "failure" | "running" | null`; `dependabot_open: null` means the
alert endpoint isn't available for that repo's owner (personal account, alert endpoint isn't available for that repo's owner (personal account,
missing token scope, or feature disabled) as opposed to `0` = "checked, none missing token scope, or feature disabled) as opposed to `0` = "checked, none
open". `--alerts` filters to repos with `dependabot_open > 0`. 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 ## Git operations
``` ```
+4
View File
@@ -21,6 +21,10 @@ pub struct RepoMeta {
pub ci_status: Option<String>, pub ci_status: Option<String>,
/// Open Dependabot alerts; None = endpoint unavailable for this repo. /// Open Dependabot alerts; None = endpoint unavailable for this repo.
pub dependabot_open: Option<u32>, pub dependabot_open: Option<u32>,
/// 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<String>,
pub fetched_at: i64, pub fetched_at: i64,
} }
+2
View File
@@ -49,6 +49,8 @@ pub enum Commands {
#[arg(long)] project: Option<String>, #[arg(long)] project: Option<String>,
/// Only repos with open Dependabot alerts (cached — run refresh first) /// Only repos with open Dependabot alerts (cached — run refresh first)
#[arg(long)] alerts: bool, #[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 /// Detailed status of one repo
Status { repo: String }, Status { repo: String },
+3 -2
View File
@@ -27,8 +27,8 @@ pub(crate) struct Guard {
/// `Config`/`Auth`/the bare TUI (those keep their existing paths in `app.rs`). /// `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<()> { pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Result<()> {
match cmd { match cmd {
Commands::List { 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), workspace::list(cfg, json, dirty, behind, ahead, lang, project, alerts, stale_deploy),
Commands::Status { repo } => workspace::status(cfg, json, &repo), Commands::Status { repo } => workspace::status(cfg, json, &repo),
Commands::Scan => workspace::scan_cmd(cfg, json), Commands::Scan => workspace::scan_cmd(cfg, json),
Commands::Sync { repos, all, project, action, message, allow_secrets } => Commands::Sync { repos, all, project, action, message, allow_secrets } =>
@@ -88,6 +88,7 @@ pub(crate) fn scan_repos(cfg: &AppConfig) -> Vec<crate::status::RepoSummary> {
r.open_prs = m.open_prs; r.open_prs = m.open_prs;
r.ci_status = m.ci_status.clone(); r.ci_status = m.ci_status.clone();
r.dependabot_open = m.dependabot_open; r.dependabot_open = m.dependabot_open;
r.deploy_status = m.deploy_status.clone();
if r.visibility.is_none() { r.visibility = m.visibility.clone(); } if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
} }
} }
+15
View File
@@ -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 results: Vec<_> = stream::iter(targets.into_iter().map(|(name, slug, branch)| {
let provider = provider.clone(); let provider = provider.clone();
async move { async move {
@@ -362,6 +372,10 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> {
.get(slug_lc.split('/').next().unwrap_or("")) .get(slug_lc.split('/').next().unwrap_or(""))
.and_then(|per_org| per_org.as_ref() .and_then(|per_org| per_org.as_ref()
.map(|m| m.get(&slug_lc).copied().unwrap_or(0))); .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 { cache.set_meta(&name, crate::cache::RepoMeta {
stars: Some(meta.stars), stars: Some(meta.stars),
open_issues: Some(meta.open_issues), open_issues: Some(meta.open_issues),
@@ -369,6 +383,7 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> {
visibility: None, visibility: None,
ci_status: ci.map(|c| c.as_str().to_string()), ci_status: ci.map(|c| c.as_str().to_string()),
dependabot_open: dep, dependabot_open: dep,
deploy_status,
fetched_at: chrono::Utc::now().timestamp(), fetched_at: chrono::Utc::now().timestamp(),
}); });
fetched_meta += 1; fetched_meta += 1;
+18 -2
View File
@@ -9,7 +9,7 @@ use anyhow::Result;
pub fn list( pub fn list(
cfg: &AppConfig, json: bool, cfg: &AppConfig, json: bool,
dirty: bool, behind: bool, ahead: bool, dirty: bool, behind: bool, ahead: bool,
lang: Option<String>, project: Option<String>, alerts: bool, lang: Option<String>, project: Option<String>, alerts: bool, stale_deploy: bool,
) -> Result<()> { ) -> Result<()> {
let mut repos = scan_repos(cfg); let mut repos = scan_repos(cfg);
@@ -17,6 +17,14 @@ pub fn list(
if behind { repos.retain(|r| r.behind > 0); } if behind { repos.retain(|r| r.behind > 0); }
if ahead { repos.retain(|r| r.ahead > 0); } if ahead { repos.retain(|r| r.ahead > 0); }
if alerts { repos.retain(|r| r.dependabot_open.unwrap_or(0) > 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 { if let Some(l) = lang {
let l = l.to_lowercase(); let l = l.to_lowercase();
repos.retain(|r| r.lang.as_deref().map(|x| x.to_lowercase().contains(&l)).unwrap_or(false)); 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.is_dirty { flags.push('⚠'); }
if r.behind > 0 { flags.push('↓'); } if r.behind > 0 { flags.push('↓'); }
if r.ahead > 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!("{:<name_w$} {:<12} {:<10} {}", r.name, r.branch, label, r.lang.as_deref().unwrap_or("-"));
} else if alerts {
println!( println!(
"{:<name_w$} {:<12} {:>3} alerts {}", "{:<name_w$} {:<12} {:>3} alerts {}",
r.name, r.branch, r.dependabot_open.unwrap_or(0), r.name, r.branch, r.dependabot_open.unwrap_or(0),
+15
View File
@@ -53,6 +53,15 @@ pub struct AppConfig {
/// no-reply address for each configured account is always matched too. /// no-reply address for each configured account is always matched too.
#[serde(default)] #[serde(default)]
pub stats_author_emails: Vec<String>, pub stats_author_emails: Vec<String>,
/// 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() } fn default_license_kind() -> String { "mit".into() }
@@ -93,6 +102,8 @@ impl Default for AppConfig {
default_license: default_license_kind(), default_license: default_license_kind(),
protected_repos: vec![], protected_repos: vec![],
stats_author_emails: 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()), ("default_license", self.default_license.clone()),
("protected_repos", self.protected_repos.join(", ")), ("protected_repos", self.protected_repos.join(", ")),
("stats_author_emails", self.stats_author_emails.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()) .filter(|s| !s.is_empty())
.collect(); .collect();
} }
"deploy_ssh_cmd" => self.deploy_ssh_cmd = value,
"deploy_root" => self.deploy_root = value,
"workspace_groups" => { "workspace_groups" => {
self.workspace_groups = value self.workspace_groups = value
.split(',') .split(',')
+129
View File
@@ -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<Self> {
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<HashMap<String, String>> {
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<String> {
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<String> {
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<u32> {
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<String, String>,
) -> Option<DeployState> {
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),
}
}
+1
View File
@@ -146,6 +146,7 @@ pub fn poll_refresh(state: &mut HubState) {
r.open_prs = meta.open_prs; r.open_prs = meta.open_prs;
r.ci_status = meta.ci_status.clone(); r.ci_status = meta.ci_status.clone();
r.dependabot_open = meta.dependabot_open; r.dependabot_open = meta.dependabot_open;
r.deploy_status = meta.deploy_status.clone();
} }
cache.set_meta(&name, meta); cache.set_meta(&name, meta);
fetched_meta += 1; fetched_meta += 1;
+1
View File
@@ -87,6 +87,7 @@ pub async fn run_overview_tui(
r.open_prs = m.open_prs; r.open_prs = m.open_prs;
r.ci_status = m.ci_status.clone(); r.ci_status = m.ci_status.clone();
r.dependabot_open = m.dependabot_open; r.dependabot_open = m.dependabot_open;
r.deploy_status = m.deploy_status.clone();
if r.visibility.is_none() { r.visibility = m.visibility.clone(); } if r.visibility.is_none() { r.visibility = m.visibility.clone(); }
} }
} }
+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()) (r.name.clone(), r.owner.clone(), basename, r.branch.clone())
}) })
.collect(); .collect();
// For the Tier-B local measurements (release cadence, repo size) below — // For the Tier-B local measurements (release cadence, repo size, deploy
// those need the on-disk path, not the provider slug. // drift) below — those need the on-disk path, not the provider slug.
let repo_paths: Vec<(String, std::path::PathBuf)> = state.repos.iter() let repo_paths: Vec<(String, std::path::PathBuf)> = state.repos.iter()
.map(|r| (r.name.clone(), r.path.clone())) .map(|r| (r.name.clone(), r.path.clone()))
.collect(); .collect();
let cfg = state.cfg.clone();
let (tx, rx) = std::sync::mpsc::channel::<RefreshMsg>(); let (tx, rx) = std::sync::mpsc::channel::<RefreshMsg>();
state.refresh_rx = Some(rx); 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 // 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 // requests took 40-80 s; this brings it to ~10 s without tripping
// GitHub's secondary rate limits. // GitHub's secondary rate limits.
@@ -806,6 +817,10 @@ pub fn spawn_api_refresh(state: &mut HubState) {
.get(slug_lc.split('/').next().unwrap_or("")) .get(slug_lc.split('/').next().unwrap_or(""))
.and_then(|per_org| per_org.as_ref() .and_then(|per_org| per_org.as_ref()
.map(|m| m.get(&slug_lc).copied().unwrap_or(0))); .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 { metas.push((name.clone(), crate::cache::RepoMeta {
stars: Some(meta.stars), stars: Some(meta.stars),
open_issues: Some(meta.open_issues), open_issues: Some(meta.open_issues),
@@ -813,6 +828,7 @@ pub fn spawn_api_refresh(state: &mut HubState) {
visibility: None, visibility: None,
ci_status: ci.map(|c| c.as_str().to_string()), ci_status: ci.map(|c| c.as_str().to_string()),
dependabot_open: dep, dependabot_open: dep,
deploy_status,
fetched_at: chrono::Utc::now().timestamp(), fetched_at: chrono::Utc::now().timestamp(),
})); }));
} }
+5 -4
View File
@@ -314,17 +314,18 @@ impl HubState {
match result { match result {
Ok(mut repo) => { Ok(mut repo) => {
repo.bookmarked = self.bookmarks.contains(&repo.path); 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| { let cache_vals = self.meta_cache.get(&repo.name).map(|e| {
(e.meta.stars, e.meta.open_issues, e.meta.open_prs, (e.meta.stars, e.meta.open_issues, e.meta.open_prs, e.meta.visibility.clone(),
e.meta.visibility.clone(), e.meta.ci_status.clone(), e.meta.dependabot_open) 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.stars = stars;
repo.open_issues = issues; repo.open_issues = issues;
repo.open_prs = prs; repo.open_prs = prs;
repo.ci_status = ci; repo.ci_status = ci;
repo.dependabot_open = dep; repo.dependabot_open = dep;
repo.deploy_status = deploy;
if repo.visibility.is_none() { repo.visibility = vis; } if repo.visibility.is_none() { repo.visibility = vis; }
} }
self.repos.push(repo); self.repos.push(repo);
+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 cur_di = state.cur_di().unwrap_or(usize::MAX);
let header = Row::new(vec![ 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)); ]).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.display.iter().enumerate() 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(0) | None => Color::DarkGray,
Some(_) => Color::Red, 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_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 stars_col = if r.stars.is_some() { Color::Yellow } else { Color::DarkGray };
let is_pinned = state.pins.contains(&r.path); 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(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(ci_str).style(Style::default().fg(ci_col)),
Cell::from(dep_str).style(Style::default().fg(dep_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)), Cell::from(stars_str).style(Style::default().fg(stars_col)),
]).style(row_sty)) ]).style(row_sty))
}) })
@@ -105,6 +113,7 @@ pub fn render_repos_table(f: &mut Frame, state: &HubState, area: Rect) {
Constraint::Length(9), Constraint::Length(9),
Constraint::Length(4), Constraint::Length(4),
Constraint::Length(6), Constraint::Length(6),
Constraint::Length(7),
Constraint::Length(8), Constraint::Length(8),
]; ];
f.render_widget(Table::new(rows, widths).header(header), area); f.render_widget(Table::new(rows, widths).header(header), area);
+1
View File
@@ -12,6 +12,7 @@ mod cli_cmds;
mod codeintel; mod codeintel;
mod config; mod config;
mod debug; mod debug;
mod deploy;
mod detail; mod detail;
mod digest; mod digest;
mod hub; mod hub;
+5 -1
View File
@@ -43,6 +43,10 @@ pub struct RepoSummary {
pub ci_status: Option<String>, pub ci_status: Option<String>,
/// Open Dependabot alerts (from the meta cache / API refresh). /// Open Dependabot alerts (from the meta cache / API refresh).
pub dependabot_open: Option<u32>, pub dependabot_open: Option<u32>,
/// 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<String>,
} }
/// One commit's display fields for a recent-commits list. /// One commit's display fields for a recent-commits list.
@@ -398,7 +402,7 @@ pub fn repo_summary(path: &Path) -> Result<RepoSummary> {
last_tag, visibility: None, owner, has_license, lang, bookmarked: false, last_tag, visibility: None, owner, has_license, lang, bookmarked: false,
stars: None, open_issues: None, open_prs: None, stars: None, open_issues: None, open_prs: None,
stash_count, stash_count,
ci_status: None, dependabot_open: None, ci_status: None, dependabot_open: None, deploy_status: None,
}) })
} }