Skip to content
478 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971//! What a repository's About reads from its files and history: its
2//! license, its security policy, its languages and its contributors.
3//!
4//! Worked out for the default branch's head in the background, never on
5//! the way to a page: a request that finds the kept answer behind the head
6//! (or none at all) answers with what is kept and starts the work in its
7//! `wait_until` (`lib.rs`, `refresh`). One run at a time per repository,
8//! by a lease in `repo_stats.started_ms`. What it reads is kept by hash in
9//! the store's cache (trees, blob sizes, history), so working out the next
10//! commit reads only what changed. A run that runs out of time keeps what
11//! it read, marked `partial`, and the next view starts another.
12
13use std::collections::{HashMap, HashSet};
14
15use g1t_contracts::about::{ABOUT_CONTRIBUTORS, Contributor, Contributors, Freshness, LanguageShare, Languages, License, WeekCommits};
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)16use g1t_contracts::account_deletion::GHOST_USERNAME;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9717use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
18use g1t_contracts::repos::{FileEntry, MAX_LISTED_FILES, Repo};
19use g1t_contracts::time::rfc3339;
20use g1t_kit::now_ms;
21use serde::{Deserialize, Serialize};
22use worker::wasm_bindgen::JsValue;
23use worker::{D1Database, Fetcher, Result};
24
25use crate::contributors::{Authored, tally};
26use crate::languages::{self, Attributes};
27use crate::license;
28use crate::store::GitRepo;
29
30/// A run older than this is taken to have died.
31pub const LEASE_MS: u64 = 120_000;
32/// How long a run reads before keeping what it has.
33const BUDGET_MS: u64 = 22_000;
34/// The most commits of history read for contributors.
35pub const MAX_HISTORY: usize = 3_000;
36/// Commits read at a time.
37const LOG_PAGE: u32 = 200;
38/// The most blobs sized for languages.
39const MAX_SIZED: usize = 6_000;
40/// Blobs sized at once: each is a round trip to the store.
41const SIZE_BATCH: usize = 32;
42/// The most distinct addresses matched to accounts.
43const MAX_MATCHED: usize = 1_000;
44/// Directories never read: nothing in them ever counts.
45const NEVER_READ: [&str; 3] = ["node_modules", "bower_components", "jspm_packages"];
46/// Where a security policy may be.
47const POLICY_DIRS: [&str; 4] = ["", ".g1t/", ".github/", "docs/"];
48
Coding agents show as themselves on commits and contributors: Claude, Codex, Copilot, Gemini, Jules, Cursor, Devin, Aider, Dependabot, Renovate and GitHub Actions are known by the address they write on their commits (never by name), shown with their maker's mark, their own name and an agent or bot label, and a card saying what each is. Anyone else not on g1t gets a card too, saying so and how to claim the commits by adding the address. Contributor lists are worked out again (stats version 2) so an agent counts once.49/// How the About is worked out (2: coding agents are contributors of their own); a row kept by an older version is worked
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)50/// out again. 1: g1t's older commit addresses count as g1t.
Coding agents show as themselves on commits and contributors: Claude, Codex, Copilot, Gemini, Jules, Cursor, Devin, Aider, Dependabot, Renovate and GitHub Actions are known by the address they write on their commits (never by name), shown with their maker's mark, their own name and an agent or bot label, and a card saying what each is. Anyone else not on g1t gets a card too, saying so and how to claim the commits by adding the address. Contributor lists are worked out again (stats version 2) so an agent counts once.51pub const STATS_VERSION: f64 = 2.0;
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)52
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9753/// What `repo_stats` keeps for the About, without the full contributors.
54#[derive(Debug, Default, Deserialize)]
55pub struct Kept {
56 pub commit_hash: Option<String>,
57 pub computed_at: Option<String>,
58 pub started_ms: Option<f64>,
59 pub partial: f64,
60 pub license: Option<String>,
61 pub security_policy: Option<String>,
62 pub languages: Option<String>,
63 pub contributors_total: f64,
64 pub contributors_top: Option<String>,
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)65 /// [`STATS_VERSION`] when it was worked out.
66 #[serde(default)]
67 pub version: f64,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9768}
69
70impl Kept {
71 /// Whether a run is under way now.
72 pub fn running(&self, now: u64) -> bool {
73 self.started_ms.is_some_and(|started| now.saturating_sub(started as u64) < LEASE_MS)
74 }
75
76 /// Whether a new run should start: the head moved past what is kept,
77 /// or what is kept is partial, and none is under way.
78 pub fn wants_run(&self, head: Option<&str>, now: u64) -> bool {
79 let Some(head) = head else { return false };
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)80 let behind = self.commit_hash.as_deref() != Some(head) || self.partial > 0.0 || self.version < STATS_VERSION;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9781 // A partial answer is tried again at most every lease.
82 let rested = self.partial == 0.0 || self.started_ms.is_none_or(|started| now.saturating_sub(started as u64) >= LEASE_MS);
83 behind && rested && !self.running(now)
84 }
85
86 pub fn freshness(&self, head: Option<String>) -> Freshness {
87 Freshness {
88 pending: head.is_some() && self.commit_hash.is_none(),
89 head,
90 commit: self.commit_hash.clone(),
91 computed_at: self.computed_at.clone(),
92 partial: self.partial > 0.0,
93 }
94 }
95
96 pub fn license(&self) -> Option<License> {
97 self.license.as_deref().and_then(|text| serde_json::from_str(text).ok())
98 }
99
100 pub fn languages(&self) -> Vec<LanguageShare> {
101 self.languages.as_deref().and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default()
102 }
103
104 pub fn top_contributors(&self) -> Vec<Contributor> {
105 self.contributors_top.as_deref().and_then(|text| serde_json::from_str(text).ok()).unwrap_or_default()
106 }
107}
108
109/// What `repo_stats` keeps for a repository; the default when nothing is.
110pub async fn kept(db: &D1Database, repo_id: &str) -> Result<Kept> {
111 Ok(db
112 .prepare(
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)113 "SELECT commit_hash, computed_at, started_ms, partial, license, security_policy, languages, contributors_total, contributors_top, version
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97114 FROM repo_stats WHERE repo_id = ?",
115 )
116 .bind(&[repo_id.into()])?
117 .first::<Kept>(None)
118 .await?
119 .unwrap_or_default())
120}
121
122/// The whole contributors answer kept for a repository.
123#[derive(Debug, Default, Serialize, Deserialize)]
124struct KeptContributors {
125 commits: u32,
126 contributors: Vec<Contributor>,
127 weeks: Vec<WeekCommits>,
128}
129
130/// The Contributors page's answer, as kept.
131pub async fn contributors(db: &D1Database, repo_id: &str, head: Option<String>) -> Result<Contributors> {
132 #[derive(Deserialize)]
133 struct Row {
134 contributors: Option<String>,
135 }
136 let summary = kept(db, repo_id).await?;
137 let full: KeptContributors = db
138 .prepare("SELECT contributors FROM repo_stats WHERE repo_id = ?")
139 .bind(&[repo_id.into()])?
140 .first::<Row>(None)
141 .await?
142 .and_then(|row| row.contributors)
143 .and_then(|text| serde_json::from_str(&text).ok())
144 .unwrap_or_default();
145 Ok(Contributors {
146 freshness: summary.freshness(head),
147 total: summary.contributors_total as u32,
148 commits: full.commits,
149 contributors: full.contributors,
150 weeks: full.weeks,
151 })
152}
153
154/// The languages answer, as kept.
155pub fn languages_of(kept: &Kept, head: Option<String>) -> Languages {
156 Languages { freshness: kept.freshness(head), languages: kept.languages() }
157}
158
159/// Takes the lease for a run, unless one is under way. Whether it was taken.
160pub async fn claim(db: &D1Database, repo_id: &str, now: u64) -> Result<bool> {
161 let taken = db
162 .prepare(
163 "INSERT INTO repo_stats (repo_id, started_ms) VALUES (?1, ?2)
164 ON CONFLICT (repo_id) DO UPDATE SET started_ms = excluded.started_ms
165 WHERE repo_stats.started_ms IS NULL OR repo_stats.started_ms < ?3
166 RETURNING repo_id",
167 )
168 .bind(&[repo_id.into(), JsValue::from_f64(now as f64), JsValue::from_f64(now.saturating_sub(LEASE_MS) as f64)])?
169 .first::<serde_json::Value>(None)
170 .await?;
171 Ok(taken.is_some())
172}
173
174/// Everything one run worked out.
175#[derive(Debug, Default)]
176pub struct Worked {
177 pub commit: String,
178 pub partial: bool,
179 pub license: Option<License>,
180 pub security_policy: Option<String>,
181 pub languages: Vec<LanguageShare>,
182 pub contributors: Vec<Contributor>,
183 pub contributors_total: u32,
184 pub commits: u32,
185 pub weeks: Vec<WeekCommits>,
186}
187
188/// Keeps a run's answer and lets the lease go. A partial answer keeps its
189/// lease's start, so the next is not tried until the lease is over.
190pub async fn keep(db: &D1Database, repo_id: &str, worked: &Worked) -> Result<()> {
191 let top: Vec<Contributor> = worked
192 .contributors
193 .iter()
194 .take(ABOUT_CONTRIBUTORS)
195 .map(|contributor| Contributor { weeks: Vec::new(), ..contributor.clone() })
196 .collect();
197 let full = KeptContributors { commits: worked.commits, contributors: worked.contributors.clone(), weeks: worked.weeks.clone() };
198 db.prepare(
199 "UPDATE repo_stats SET commit_hash = ?2, computed_at = ?3, started_ms = CASE WHEN ?4 = 1 THEN started_ms ELSE NULL END,
200 partial = ?4, license = ?5, security_policy = ?6, languages = ?7, contributors_total = ?8, contributors_top = ?9,
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)201 contributors = ?10, version = ?11
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97202 WHERE repo_id = ?1",
203 )
204 .bind(&[
205 repo_id.into(),
206 worked.commit.as_str().into(),
207 rfc3339(now_ms()).into(),
208 JsValue::from_f64(if worked.partial { 1.0 } else { 0.0 }),
209 match &worked.license {
210 Some(license) => json(license).into(),
211 None => JsValue::NULL,
212 },
213 match &worked.security_policy {
214 Some(path) => path.as_str().into(),
215 None => JsValue::NULL,
216 },
217 json(&worked.languages).into(),
218 JsValue::from_f64(f64::from(worked.contributors_total)),
219 json(&top).into(),
220 json(&full).into(),
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)221 JsValue::from_f64(STATS_VERSION),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97222 ])?
223 .run()
224 .await?;
225 Ok(())
226}
227
228/// Lets the lease go after a run that failed, so the next view tries again
229/// once the lease would have ended anyway.
230pub async fn release(db: &D1Database, repo_id: &str) {
231 let statement = db
232 .prepare("UPDATE repo_stats SET started_ms = ?2 WHERE repo_id = ?1")
233 .bind(&[repo_id.into(), JsValue::from_f64(now_ms().saturating_sub(LEASE_MS / 2) as f64)]);
234 if let Ok(statement) = statement
235 && let Err(error) = statement.run().await
236 {
237 worker::console_error!("the About's lease of {repo_id} was not let go: {error}");
238 }
239}
240
241fn json<T: Serialize>(value: &T) -> String {
242 serde_json::to_string(value).unwrap_or_else(|_| "null".to_owned())
243}
244
245/// The security policy among a repository's files, at the root or in
246/// `.g1t`, `.github` or `docs`.
247pub fn security_policy(files: &[FileEntry]) -> Option<String> {
248 POLICY_DIRS.iter().find_map(|dir| {
249 files
250 .iter()
251 .find(|file| {
252 file.path
253 .strip_prefix(dir)
254 .is_some_and(|name| !name.contains('/') && (name.eq_ignore_ascii_case("security.md") || name.eq_ignore_ascii_case("security.txt") && dir.is_empty()))
255 })
256 .map(|file| file.path.clone())
257 })
258}
259
260/// Works out the About of `repo` at `head` (a commit). `identity` matches
261/// authors' addresses to accounts.
262pub async fn work_out<R: GitRepo>(git: &R, head: &str, tree: &str, identity: Option<&Fetcher>) -> Result<Worked> {
263 let started = now_ms();
264 let out_of_time = move || now_ms().saturating_sub(started) > BUDGET_MS;
265 let never: Vec<String> = NEVER_READ.iter().map(|dir| (*dir).to_owned()).collect();
266 let files = async {
267 let (files, truncated) = crate::listing::changed(git, None, tree, &never, MAX_LISTED_FILES).await?;
268 let root: Vec<&FileEntry> = files.iter().filter(|file| !file.path.contains('/')).collect();
269 // The license: the best-named license file that reads as text.
270 let mut license = None;
271 for name in license::candidates(root.iter().map(|file| file.path.as_str())) {
272 let Some(hash) = root.iter().find(|file| file.path == name).and_then(|file| file.hash.clone()) else { continue };
273 if let Some(text) = git.read_blob(&hash).await?.and_then(|bytes| String::from_utf8(bytes).ok()) {
274 license = Some(license::detect(name, &text));
275 break;
276 }
277 }
278 let attributes = match root.iter().find(|file| file.path == ".gitattributes").and_then(|file| file.hash.clone()) {
279 Some(hash) => git
280 .read_blob(&hash)
281 .await?
282 .map(|bytes| Attributes::parse(&String::from_utf8_lossy(&bytes)))
283 .unwrap_or_default(),
284 None => Attributes::default(),
285 };
286 let policy = security_policy(&files);
287 // Each counted file's language, its blob sized once.
288 let mut counted: Vec<(&'static languages::Language, String)> = Vec::new();
289 for file in &files {
290 let Some(hash) = &file.hash else { continue };
291 if let Some(language) = languages::counted(&file.path, &attributes) {
292 counted.push((language, hash.clone()));
293 }
294 }
295 let mut partial = truncated || counted.len() > MAX_SIZED;
296 counted.truncate(MAX_SIZED);
297 let distinct: Vec<String> = counted.iter().map(|(_, hash)| hash.clone()).collect::<HashSet<_>>().into_iter().collect();
298 let mut sizes: HashMap<String, u64> = HashMap::with_capacity(distinct.len());
299 for batch in distinct.chunks(SIZE_BATCH) {
300 if out_of_time() {
301 partial = true;
302 break;
303 }
304 let found = futures_util::future::join_all(batch.iter().map(|hash| git.blob_size(hash))).await;
305 for (hash, size) in batch.iter().zip(found) {
306 if let Ok(Some(size)) = size {
307 sizes.insert(hash.clone(), size);
308 }
309 }
310 }
311 let shares = languages::shares(counted.iter().filter_map(|(language, hash)| Some((*language, *sizes.get(hash)?))));
312 Ok::<_, worker::Error>((license, policy, shares, partial))
313 };
314 let history = async {
315 let mut commits: Vec<Authored> = Vec::new();
316 let mut next = Some(head.to_owned());
317 let mut partial = false;
318 while let Some(from) = next.take() {
319 let page = git.log(&from, LOG_PAGE).await?;
320 let more = page.len() == LOG_PAGE as usize;
321 let after = page.last().and_then(|commit| commit.parents.first().cloned());
322 for commit in page {
323 commits.push(Authored { name: commit.author.name, email: commit.author.email, at: commit.authored_at });
324 }
325 if more && after.is_some() {
326 if commits.len() >= MAX_HISTORY || out_of_time() {
327 partial = true;
328 } else {
329 next = after;
330 }
331 }
332 }
333 commits.truncate(MAX_HISTORY);
334 Ok::<_, worker::Error>((commits, partial))
335 };
336 let (files, history) = futures_util::future::join(files, history).await;
337 let (license, security_policy, languages, files_partial) = files?;
338 let (commits, history_partial) = history?;
339 let owners = match identity {
340 Some(identity) => owners(identity, &commits).await,
341 None => HashMap::new(),
342 };
343 let (contributors, contributors_total, weeks) = tally(&commits, &owners);
344 Ok(Worked {
345 commit: head.to_owned(),
346 partial: files_partial || history_partial,
347 license,
348 security_policy,
349 languages,
350 contributors,
351 contributors_total,
352 commits: commits.len() as u32,
353 weeks,
354 })
355}
356
357/// The accounts behind the commits' addresses, the most frequent first. A
358/// failure matches none: the authors show by name.
359async fn owners(identity: &Fetcher, commits: &[Authored]) -> HashMap<String, EmailOwner> {
360 let mut counts: HashMap<String, usize> = HashMap::new();
361 for commit in commits.iter().filter(|commit| !crate::contributors::is_g1t(commit)) {
362 let email = commit.email.trim().to_ascii_lowercase();
363 if !email.is_empty() {
364 *counts.entry(email).or_default() += 1;
365 }
366 }
367 let mut emails: Vec<(String, usize)> = counts.into_iter().collect();
368 emails.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
369 emails.truncate(MAX_MATCHED);
370 let mut owners = HashMap::new();
371 for chunk in emails.chunks(200) {
372 let args = EmailOwnersArgs { emails: chunk.iter().map(|(email, _)| email.clone()).collect() };
373 match g1t_kit::call::<_, HashMap<String, EmailOwner>>(identity, "email_owners", &args).await {
374 Ok(found) => owners.extend(found),
375 Err(error) => worker::console_error!("contributors' accounts not matched: {error}"),
376 }
377 }
378 owners
379}
380
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)381/// What a kept contributors answer says when it names an account that
382/// `kind` (a `user.deleting` or `user.restored` event with `data`) changes:
383/// a deleted account's username, which is ghost from now on, or ghost's,
384/// which a restored account may be among. `None` for any other event.
385pub fn shown_differently(kind: &str, data: &serde_json::Value) -> Option<String> {
386 let username = match kind {
387 "user.deleting" => data["username"].as_str().filter(|name| !name.is_empty())?.to_owned(),
388 "user.restored" => GHOST_USERNAME.to_owned(),
389 _ => return None,
390 };
391 Some(format!("\"username\":{}", serde_json::Value::String(username)))
392}
393
394/// Marks every repository whose kept contributors contain `needle` (see
395/// [`shown_differently`]) to be worked out again on its next view, as an
396/// older version's would be.
397pub async fn rework_naming(db: &D1Database, needle: &str) -> Result<()> {
398 db.prepare("UPDATE repo_stats SET version = 0 WHERE instr(contributors, ?1) > 0")
399 .bind(&[needle.into()])?
400 .run()
401 .await?;
402 Ok(())
403}
404
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97405/// Whether the repository is one whose About is worked out: not a pull
406/// request's working copy.
407pub fn has_about(repo: &Repo) -> bool {
408 repo.fork_of.is_none()
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn file(path: &str) -> FileEntry {
416 FileEntry { path: path.into(), hash: Some("h".into()) }
417 }
418
419 #[test]
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)420 fn deleting_or_restoring_an_account_reworks_the_answers_that_name_it() {
421 let kept = serde_json::to_string(&Contributor {
422 kind: g1t_contracts::about::ContributorKind::User,
423 name: "ana".into(),
424 username: Some("ana".into()),
425 avatar: None,
Coding agents show as themselves on commits and contributors: Claude, Codex, Copilot, Gemini, Jules, Cursor, Devin, Aider, Dependabot, Renovate and GitHub Actions are known by the address they write on their commits (never by name), shown with their maker's mark, their own name and an agent or bot label, and a card saying what each is. Anyone else not on g1t gets a card too, saying so and how to claim the commits by adding the address. Contributor lists are worked out again (stats version 2) so an agent counts once.426 agent: None,
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)427 commits: 3,
428 first_at: "2026-10-01T00:00:00Z".into(),
429 last_at: "2026-10-02T00:00:00Z".into(),
430 weeks: Vec::new(),
431 })
432 .unwrap();
433 let deleting = shown_differently("user.deleting", &serde_json::json!({ "userId": "usr_ana", "username": "ana" })).unwrap();
434 assert!(kept.contains(&deleting));
435 // Not a longer name that starts the same.
436 assert!(!kept.replace("\"ana\"", "\"anabel\"").contains(&deleting));
437 // A restored account may be any ghost: those answers are worked out again.
438 let restored = shown_differently("user.restored", &serde_json::json!({ "userId": "usr_ana", "username": "ana" })).unwrap();
439 assert!(kept.replace("\"ana\"", "\"ghost\"").contains(&restored));
440 assert_eq!(shown_differently("user.updated", &serde_json::json!({ "username": "ana" })), None);
441 assert_eq!(shown_differently("user.deleting", &serde_json::json!({})), None);
442 }
443
444 #[test]
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97445 fn a_security_policy_is_found_where_it_may_be() {
446 assert_eq!(security_policy(&[file("src/SECURITY.md"), file(".github/SECURITY.md")]).as_deref(), Some(".github/SECURITY.md"));
447 assert_eq!(security_policy(&[file("docs/security.md"), file("SECURITY.md")]).as_deref(), Some("SECURITY.md"));
448 assert_eq!(security_policy(&[file(".g1t/SECURITY.md")]).as_deref(), Some(".g1t/SECURITY.md"));
449 assert_eq!(security_policy(&[file("SECURITY.txt")]).as_deref(), Some("SECURITY.txt"));
450 assert_eq!(security_policy(&[file("docs/guides/SECURITY.md"), file("README.md")]), None);
451 }
452
453 #[test]
454 fn a_run_starts_when_the_head_moved_and_none_is_under_way() {
455 let now = 1_000_000;
456 let none = Kept::default();
457 assert!(none.wants_run(Some("c2"), now));
458 assert!(!none.wants_run(None, now), "an empty repository has nothing to work out");
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)459 let current = Kept { commit_hash: Some("c2".into()), version: STATS_VERSION, ..Kept::default() };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97460 assert!(!current.wants_run(Some("c2"), now));
461 assert!(current.wants_run(Some("c3"), now));
Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016)462 let older = Kept { commit_hash: Some("c2".into()), version: STATS_VERSION - 1.0, ..Kept::default() };
463 assert!(older.wants_run(Some("c2"), now), "an answer worked out the old way is worked out again");
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97464 let running = Kept { commit_hash: Some("c2".into()), started_ms: Some((now - 1_000) as f64), ..Kept::default() };
465 assert!(!running.wants_run(Some("c3"), now));
466 let died = Kept { commit_hash: Some("c2".into()), started_ms: Some((now - LEASE_MS - 1) as f64), ..Kept::default() };
467 assert!(died.wants_run(Some("c3"), now));
468 let partial = Kept { commit_hash: Some("c2".into()), partial: 1.0, started_ms: Some((now - 5_000) as f64), ..Kept::default() };
469 assert!(!partial.wants_run(Some("c2"), now), "a partial answer rests a lease before the next try");
470 }
471
472 #[test]
473 fn nothing_kept_is_pending() {
474 let freshness = Kept::default().freshness(Some("c1".into()));
475 assert!(freshness.pending);
476 assert!(!Kept::default().freshness(None).pending, "an empty repository is not waiting on anything");
477 }
478}

This file's history is long; its oldest lines are credited to the oldest commit read.