Skip to main content

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}