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}).
31        let cached_branch = crate::cache::cached_branch(owner, repo);
32        let branch = match &cached_branch {
33            Some(b) => {
34                rootle_provider::trace(&format!("tree branch cached {owner}/{repo} {b}"));
35                b.clone()
36            }
37            None => {
38                rootle_provider::trace(&format!("tree branch meta-fetch {owner}/{repo}"));
39                let meta: RepoMeta = self.get(&format!("{API}/repos/{owner}/{repo}"))?;
40                meta.default_branch
41            }
42        };
43        match self.fetch_tree_on(owner, repo, &branch) {
44            // The default branch was renamed since we cached it:
45            // resolve fresh and try once more.
46            Err(e) if cached_branch.is_some() && e.kind == ErrorKind::NotFound => {
47                let meta: RepoMeta = self.get(&format!("{API}/repos/{owner}/{repo}"))?;
48                self.fetch_tree_on(owner, repo, &meta.default_branch)
49            }
50            other => other,
51        }
52    }
53
54    fn fetch_tree_on(
55        &self,
56        owner: &str,
57        repo: &str,
58        branch: &str,
59    ) -> ProviderResult<(
60        TreeResponse,
61        /*truncated*/ bool,
62        /*branch*/ String,
63    )> {
64        let cached_ref = crate::cache::read_ref(owner, repo, branch);
65        let url = format!("{API}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1");
66
67        let etag = cached_ref.as_ref().and_then(|r| r.etag.clone());
68        match self.get_conditional::<TreeResponse>(&url, etag.as_deref())? {
69            Conditional::NotModified => {
70                let sha = cached_ref.expect("304 without a cached ref").tree_sha;
71                match crate::cache::read_tree(&sha) {
72                    Some(tree) => Ok((tree.clone(), tree.truncated, branch.to_string())),
73                    None => {
74                        // A cache read that cannot be satisfied is a
75                        // miss, not an error. The startup orphan sweep
76                        // can race a fetch's tree-then-ref write order
77                        // and delete a tree its ref already points at;
78                        // the etag then 304s forever against a missing
79                        // body (sticky unopenable repo). Refetch
80                        // unconditionally โ€” the cache is only an
81                        // optimization, and this re-stores the tree
82                        // and ref, healing both.
83                        rootle_provider::trace(&format!(
84                            "304 but tree {sha} missing from cache; refetching"
85                        ));
86                        let Conditional::Fresh { body, etag } =
87                            self.get_conditional::<TreeResponse>(&url, None)?
88                        else {
89                            return Err(ProviderError::other(
90                                "unconditional revalidation returned 304",
91                            ));
92                        };
93                        self.store_tree(owner, repo, branch, body, etag)
94                    }
95                }
96            }
97            Conditional::Fresh { body, etag } => self.store_tree(owner, repo, branch, body, etag),
98        }
99    }
100
101    /// Persist a fetched tree + its ref (tree first โ€” durable before
102    /// discoverable; the sweep race above is absorbed by the miss
103    /// fallback).
104    fn store_tree(
105        &self,
106        owner: &str,
107        repo: &str,
108        branch: &str,
109        body: TreeResponse,
110        etag: Option<String>,
111    ) -> ProviderResult<(
112        TreeResponse,
113        /*truncated*/ bool,
114        /*branch*/ String,
115    )> {
116        crate::cache::write_tree(&body).map_err(|e| ProviderError::other(e.to_string()))?;
117        crate::cache::write_ref(
118            owner,
119            repo,
120            branch,
121            &crate::cache::RefCache {
122                tree_sha: body.sha.clone(),
123                etag,
124            },
125        )
126        .map_err(|e| ProviderError::other(e.to_string()))?;
127        let truncated = body.truncated;
128        Ok((body, truncated, branch.to_string()))
129    }
130}