| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
use tokio::process::Command; |
| 16 |
|
| 17 |
use crate::config::{Config, RepoConfig}; |
| 18 |
use crate::db; |
| 19 |
use crate::error::Result; |
| 20 |
use crate::types::VersionRow; |
| 21 |
|
| 22 |
|
| 23 |
pub async fn collect(pool: &sqlx::SqlitePool, config: &Config) -> Result<Vec<VersionRow>> { |
| 24 |
let mut rows = Vec::new(); |
| 25 |
|
| 26 |
for name in config.target_names() { |
| 27 |
let target = config.get_target(&name).unwrap(); |
| 28 |
let health = db::get_latest_health(pool, &name).await?; |
| 29 |
let details = health.as_ref().and_then(|h| h.details.as_ref()); |
| 30 |
let git_sha = details.and_then(|d| d.git_sha.clone()); |
| 31 |
|
| 32 |
let (commits_behind, behind_error) = match (&target.repo, &git_sha) { |
| 33 |
(None, _) => (None, None), |
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 38 |
(Some(_), None) => ( |
| 39 |
None, |
| 40 |
health |
| 41 |
.as_ref() |
| 42 |
.map(|_| "target reports no git_sha in its health body".to_string()), |
| 43 |
), |
| 44 |
(Some(repo), Some(sha)) => match commits_behind(repo, sha).await { |
| 45 |
Ok(n) => (Some(n), None), |
| 46 |
Err(e) => (None, Some(e)), |
| 47 |
}, |
| 48 |
}; |
| 49 |
|
| 50 |
let version = details.and_then(|d| d.version.clone()); |
| 51 |
let version_since = match &version { |
| 52 |
Some(v) => db::get_version_first_seen(pool, &name, v).await?, |
| 53 |
None => None, |
| 54 |
}; |
| 55 |
|
| 56 |
rows.push(VersionRow { |
| 57 |
target: name, |
| 58 |
label: target.label.clone(), |
| 59 |
version, |
| 60 |
git_sha, |
| 61 |
checked_at: health.as_ref().map(|h| h.checked_at.clone()), |
| 62 |
version_since, |
| 63 |
commits_behind, |
| 64 |
behind_error, |
| 65 |
}); |
| 66 |
} |
| 67 |
|
| 68 |
Ok(rows) |
| 69 |
} |
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
|
| 75 |
|
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
|
| 80 |
async fn commits_behind(repo: &RepoConfig, sha: &str) -> std::result::Result<i64, String> { |
| 81 |
if !is_object_name(sha) { |
| 82 |
return Err(format!("target reported an unusable git_sha: {sha:?}")); |
| 83 |
} |
| 84 |
|
| 85 |
let mut cmd = Command::new("git"); |
| 86 |
cmd.arg("-C").arg(&repo.path); |
| 87 |
cmd.args(["rev-list", "--count", &format!("{sha}..HEAD")]); |
| 88 |
if let Some(subdir) = &repo.subdir { |
| 89 |
cmd.arg("--").arg(subdir); |
| 90 |
} |
| 91 |
|
| 92 |
let output = cmd |
| 93 |
.output() |
| 94 |
.await |
| 95 |
.map_err(|e| format!("could not run git in {}: {e}", repo.path.display()))?; |
| 96 |
|
| 97 |
if !output.status.success() { |
| 98 |
|
| 99 |
|
| 100 |
|
| 101 |
let stderr = String::from_utf8_lossy(&output.stderr); |
| 102 |
return Err(format!( |
| 103 |
"git rev-list exited {}: {}", |
| 104 |
output.status.code().unwrap_or(-1), |
| 105 |
stderr.trim() |
| 106 |
)); |
| 107 |
} |
| 108 |
|
| 109 |
parse_count(&String::from_utf8_lossy(&output.stdout)) |
| 110 |
} |
| 111 |
|
| 112 |
|
| 113 |
fn parse_count(stdout: &str) -> std::result::Result<i64, String> { |
| 114 |
let trimmed = stdout.trim(); |
| 115 |
trimmed |
| 116 |
.parse::<i64>() |
| 117 |
.map_err(|_| format!("unparseable rev-list count: {trimmed:?}")) |
| 118 |
} |
| 119 |
|
| 120 |
|
| 121 |
|
| 122 |
|
| 123 |
fn is_object_name(s: &str) -> bool { |
| 124 |
(4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit()) |
| 125 |
} |
| 126 |
|
| 127 |
#[cfg(test)] |
| 128 |
mod tests { |
| 129 |
use super::*; |
| 130 |
|
| 131 |
#[test] |
| 132 |
fn parses_a_count() { |
| 133 |
assert_eq!(parse_count("8\n").unwrap(), 8); |
| 134 |
assert_eq!(parse_count("0").unwrap(), 0); |
| 135 |
} |
| 136 |
|
| 137 |
#[test] |
| 138 |
fn rejects_non_numeric_output() { |
| 139 |
assert!(parse_count("").is_err()); |
| 140 |
assert!(parse_count("fatal: bad revision\n").is_err()); |
| 141 |
} |
| 142 |
|
| 143 |
#[test] |
| 144 |
fn accepts_full_and_abbreviated_shas() { |
| 145 |
assert!(is_object_name("6402bf4e")); |
| 146 |
assert!(is_object_name("6402bf4e0000000000000000000000000000abcd")); |
| 147 |
} |
| 148 |
|
| 149 |
#[test] |
| 150 |
fn rejects_anything_git_could_read_as_an_option() { |
| 151 |
assert!(!is_object_name("--upload-pack=touch /tmp/pwned")); |
| 152 |
assert!(!is_object_name("-C/etc")); |
| 153 |
assert!(!is_object_name("main")); |
| 154 |
assert!(!is_object_name("6402bf4e; rm -rf /")); |
| 155 |
assert!(!is_object_name("abc")); |
| 156 |
assert!(!is_object_name("")); |
| 157 |
} |
| 158 |
} |
| 159 |
|