rootle_github/client/trees.rs
1//! Recursive trees, conditional ref revalidation and cache healing.
2
3use super::transport::Conditional;
4use super::{API, GitHubClient};
5use crate::types::{RepoMeta, TreeResponse};
6use rootle_provider::{ErrorKind, ProviderError, ProviderResult};
7
8impl GitHubClient {
9 /// Fetch a repo's full recursive tree with the sha-keyed cache
10 /// (PLAN.md ยง8): revalidate the branch ref with If-None-Match
11 /// (304 = free, tree unchanged), cache tree bodies by their sha.
12 /// Also returns the default branch (URL building, yank). v1.5:
13 /// `ref_` pins another branch/tag/sha โ the tree endpoint takes
14 /// any ref, and a sha's tree is immutable, so the same etag
15 /// revalidation is correct there.
16 pub fn fetch_tree(
17 &self,
18 owner: &str,
19 repo: &str,
20 ref_: Option<&str>,
21 ) -> ProviderResult<(
22 TreeResponse,
23 /*truncated*/ bool,
24 /*branch*/ String,
25 )> {
26 if let Some(r) = ref_ {
27 return self.fetch_tree_on(owner, repo, r);
28 }
29 // Cache-first branch resolution: a repo we've opened before
30 // costs zero extra calls here (no GET /repos/{o}/{r}). The
31 // hit/miss decision is recorded by `cache::cached_branch`.
32 let cached_branch = crate::cache::cached_branch(owner, repo);
33 let branch = match &cached_branch {
34 Some(b) => b.clone(),
35 None => {
36 let meta: RepoMeta = self.get(&format!("{API}/repos/{owner}/{repo}"))?;
37 meta.default_branch
38 }
39 };
40 match self.fetch_tree_on(owner, repo, &branch) {
41 // The default branch was renamed since we cached it:
42 // resolve fresh and try once more.
43 Err(e) if cached_branch.is_some() && e.kind == ErrorKind::NotFound => {
44 let meta: RepoMeta = self.get(&format!("{API}/repos/{owner}/{repo}"))?;
45 self.fetch_tree_on(owner, repo, &meta.default_branch)
46 }
47 other => other,
48 }
49 }
50
51 fn fetch_tree_on(
52 &self,
53 owner: &str,
54 repo: &str,
55 branch: &str,
56 ) -> ProviderResult<(
57 TreeResponse,
58 /*truncated*/ bool,
59 /*branch*/ String,
60 )> {
61 let cached_ref = crate::cache::read_ref(owner, repo, branch);
62 let url = format!("{API}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1");
63
64 let etag = cached_ref.as_ref().and_then(|r| r.etag.clone());
65 match self.get_conditional::<TreeResponse>(&url, etag.as_deref())? {
66 Conditional::NotModified => {
67 let sha = cached_ref.expect("304 without a cached ref").tree_sha;
68 match crate::cache::read_tree(&sha) {
69 Some(tree) => Ok((tree.clone(), tree.truncated, branch.to_string())),
70 None => {
71 // A cache read that cannot be satisfied is a
72 // miss, not an error. The startup orphan sweep
73 // can race a fetch's tree-then-ref write order
74 // and delete a tree its ref already points at;
75 // the etag then 304s forever against a missing
76 // body (sticky unopenable repo). Refetch
77 // unconditionally โ the cache is only an
78 // optimization, and this re-stores the tree
79 // and ref, healing both.
80 rootle_trace::record_with(rootle_trace::EventKind::Cache, || {
81 serde_json::json!({
82 "op": "tree_missing_after_304",
83 "owner": owner,
84 "repo": repo,
85 "branch": branch,
86 "sha": sha,
87 })
88 });
89 let Conditional::Fresh { body, etag } =
90 self.get_conditional::<TreeResponse>(&url, None)?
91 else {
92 return Err(ProviderError::other(
93 "unconditional revalidation returned 304",
94 ));
95 };
96 self.store_tree(owner, repo, branch, body, etag)
97 }
98 }
99 }
100 Conditional::Fresh { body, etag } => self.store_tree(owner, repo, branch, body, etag),
101 }
102 }
103
104 /// Persist a fetched tree + its ref (tree first โ durable before
105 /// discoverable; the sweep race above is absorbed by the miss
106 /// fallback).
107 fn store_tree(
108 &self,
109 owner: &str,
110 repo: &str,
111 branch: &str,
112 body: TreeResponse,
113 etag: Option<String>,
114 ) -> ProviderResult<(
115 TreeResponse,
116 /*truncated*/ bool,
117 /*branch*/ String,
118 )> {
119 crate::cache::write_tree(&body).map_err(|e| ProviderError::other(e.to_string()))?;
120 crate::cache::write_ref(
121 owner,
122 repo,
123 branch,
124 &crate::cache::RefCache {
125 tree_sha: body.sha.clone(),
126 etag,
127 },
128 )
129 .map_err(|e| ProviderError::other(e.to_string()))?;
130 let truncated = body.truncated;
131 Ok((body, truncated, branch.to_string()))
132 }
133}