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
+29
-4
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user