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