Skip to main content

grit_lib/remote/
inspect.rs

1//! [`RepositoryRemotes::inspect`](crate::repo_handles::RepositoryRemotes::inspect) types and logic.
2
3use std::collections::{BTreeMap, BTreeSet, HashSet};
4
5use crate::branch_tracking::remote_tracking_ref_for_remote_ref;
6use crate::config::ConfigSet;
7use crate::error::Result;
8use crate::objects::ObjectId;
9use crate::refs;
10use crate::refspec::{parse_fetch_refspec, RefspecItem};
11use crate::remote::{HttpClientFactory, ListRefsOptions, Remote, RemoteError, RemoteRef};
12use crate::repo::Repository;
13use crate::repo_handles::has_remote_section;
14use crate::transfer::{match_positive, ref_excluded, tracking_refs_gone_in};
15
16/// Options for [`RepositoryRemotes::inspect`](crate::repo_handles::RepositoryRemotes::inspect).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct InspectOptions {
19    /// When `true`, contact the remote (or read its git dir) to classify branch states.
20    pub query: bool,
21    /// Maximum number of entries in [`RemoteSummary::branches`] (`None` = unlimited).
22    pub limit: Option<usize>,
23}
24
25impl Default for InspectOptions {
26    fn default() -> Self {
27        Self {
28            query: true,
29            limit: None,
30        }
31    }
32}
33
34/// Whether a remote branch is tracked locally, new on the remote, or stale locally.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
36pub enum RemoteBranchStateKind {
37    /// A remote-tracking ref exists for this branch.
38    Tracked,
39    /// The branch exists on the remote but not as a local tracking ref.
40    New,
41    /// A local tracking ref exists but the branch is gone on the remote.
42    Stale,
43}
44
45/// One remote branch and how it relates to local tracking refs.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RemoteBranchState {
48    /// Short branch name (the segment under `refs/heads/`).
49    pub name: String,
50    /// Classification relative to local tracking refs.
51    pub state: RemoteBranchStateKind,
52}
53
54/// A local branch configured to pull from a remote via `branch.*` config.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct RemotePullBranch {
57    /// Local branch short name (`branch.<local>.remote` matches the remote).
58    pub local: String,
59    /// Value of `branch.<local>.merge` (typically `refs/heads/<branch>`).
60    pub merges: String,
61}
62
63/// Result of inspecting one configured remote.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RemoteSummary {
66    /// Remote name (config subsection).
67    pub name: String,
68    /// Fetch URL after config rewrite.
69    pub fetch_url: String,
70    /// Push URL (`pushurl` when set, else fetch URL).
71    pub push_url: String,
72    /// Default branch on the remote (`HEAD` symref target), when known.
73    pub head_branch: Option<String>,
74    /// Remote branches and their tracking state (bounded by [`InspectOptions::limit`]).
75    pub branches: Vec<RemoteBranchState>,
76    /// Local branches with `branch.<name>.remote` set to this remote.
77    pub pull: Vec<RemotePullBranch>,
78}
79
80struct FetchRefspecs {
81    positive: Vec<RefspecItem>,
82    negative: Vec<RefspecItem>,
83}
84
85impl FetchRefspecs {
86    fn load(config: &ConfigSet, remote_name: &str) -> Self {
87        let (negative, mut positive): (Vec<RefspecItem>, Vec<RefspecItem>) = config
88            .get_all(&format!("remote.{remote_name}.fetch"))
89            .iter()
90            .filter_map(|spec| parse_fetch_refspec(spec).ok())
91            .partition(|item| item.negative);
92        if positive.is_empty() {
93            if let Ok(default) =
94                parse_fetch_refspec(&format!("+refs/heads/*:refs/remotes/{remote_name}/*"))
95            {
96                positive.push(default);
97            }
98        }
99        Self { positive, negative }
100    }
101}
102
103/// Inspect remote `name` and return URLs, default branch, branch states, and pull config.
104///
105/// # Errors
106///
107/// - [`RemoteError::UnknownRemote`] when no `remote.<name>.*` section exists.
108/// - [`RemoteError::NoUrl`] when the remote has no URL.
109/// - Propagates transport, config, and ref I/O failures as [`RemoteError::Library`].
110pub fn inspect_remote(
111    repo: &Repository,
112    name: &str,
113    options: &InspectOptions,
114    http_factory: Option<&dyn HttpClientFactory>,
115) -> std::result::Result<RemoteSummary, RemoteError> {
116    let config = repo.config().map_err(RemoteError::Library)?;
117    if !has_remote_section(config.as_ref(), name) {
118        return Err(RemoteError::UnknownRemote(name.to_owned()));
119    }
120    let remote = Remote::from_config(config.as_ref(), name)?;
121    let fetch_url = remote.fetch_url().to_url_string();
122    let push_url = remote.push_url().to_url_string();
123
124    let (head_branch, branches) = if options.query {
125        let refspecs = FetchRefspecs::load(config.as_ref(), name);
126        let list_opts = ListRefsOptions {
127            symrefs: true,
128            ..Default::default()
129        };
130        let advertised = remote.list_refs(Some(repo), &list_opts, http_factory)?;
131        let head_branch = head_branch_from_advertisement(&advertised);
132        let branches =
133            branch_states_from_query(repo, config.as_ref(), name, &refspecs, &advertised)?;
134        (head_branch, branches)
135    } else {
136        let refspecs = FetchRefspecs::load(config.as_ref(), name);
137        let head_branch = local_remote_head_branch(repo, name)?;
138        let branches = local_tracking_branch_states(repo, name, &refspecs);
139        (head_branch, branches)
140    };
141
142    let branches = apply_branch_limit(branches, options.limit);
143    let pull = collect_pull_branches(config.as_ref(), name);
144
145    Ok(RemoteSummary {
146        name: name.to_owned(),
147        fetch_url,
148        push_url,
149        head_branch,
150        branches,
151        pull,
152    })
153}
154
155fn head_branch_from_advertisement(refs: &[RemoteRef]) -> Option<String> {
156    refs.iter()
157        .find(|r| r.name == "HEAD")
158        .and_then(|r| r.symref_target.as_ref())
159        .map(|t| t.strip_prefix("refs/heads/").unwrap_or(t).to_owned())
160}
161
162fn local_remote_head_branch(repo: &Repository, remote_name: &str) -> Result<Option<String>> {
163    let head_ref = format!("refs/remotes/{remote_name}/HEAD");
164    let Some(target) = refs::read_symbolic_ref_for_repository(repo, &head_ref)? else {
165        return Ok(None);
166    };
167    Ok(target
168        .strip_prefix(&format!("refs/remotes/{remote_name}/"))
169        .or_else(|| target.strip_prefix("refs/heads/"))
170        .map(str::to_owned))
171}
172
173fn local_tracking_branch_states(
174    repo: &Repository,
175    remote_name: &str,
176    refspecs: &FetchRefspecs,
177) -> Vec<RemoteBranchState> {
178    local_tracked_head_short_names(repo, remote_name, refspecs)
179        .into_iter()
180        .map(|name| RemoteBranchState {
181            name,
182            state: RemoteBranchStateKind::Tracked,
183        })
184        .collect()
185}
186
187/// Short branch names (`refs/heads/<name>`) with a local tracking ref under fetch refspec destinations.
188fn local_tracked_head_short_names(
189    repo: &Repository,
190    remote_name: &str,
191    refspecs: &FetchRefspecs,
192) -> Vec<String> {
193    let mut names: BTreeSet<String> = BTreeSet::new();
194    for item in &refspecs.positive {
195        let Some(dst) = item.dst.as_deref() else {
196            continue;
197        };
198        if let Some(star) = dst.find('*') {
199            let prefix = &dst[..star];
200            let Ok(entries) = refs::list_refs_in(repo, prefix) else {
201                continue;
202            };
203            for (refname, _) in entries {
204                if refname.ends_with("/HEAD") {
205                    continue;
206                }
207                if let Some(suffix) = refname.strip_prefix(prefix) {
208                    names.insert(suffix.to_owned());
209                }
210            }
211        } else if refs::resolve_ref_in(repo, dst).is_ok() {
212            if let Some(src) = item.src.as_deref() {
213                if let Some(short) = src.strip_prefix("refs/heads/") {
214                    names.insert(short.to_owned());
215                }
216            }
217        }
218    }
219    if names.is_empty() {
220        let prefix = format!("refs/remotes/{remote_name}/");
221        if let Ok(entries) = refs::list_refs_in(repo, &prefix) {
222            for (refname, _) in entries {
223                if refname == format!("{prefix}HEAD") {
224                    continue;
225                }
226                if let Some(short) = refname.strip_prefix(&prefix) {
227                    names.insert(short.to_owned());
228                }
229            }
230        }
231    }
232    names.into_iter().collect()
233}
234
235/// Resolvable local refs under the wildcard destinations of the fetch refspecs.
236///
237/// Listing each destination prefix once keeps the tracked check linear in the ref count:
238/// resolving every remote branch separately re-reads `packed-refs` per branch.
239///
240/// Only prefixes ending in `/` are listed: [`refs::list_refs`] treats any other prefix as a
241/// directory name, so names that merely extend it (`refs/remotes/origin-main` for
242/// `refs/remotes/origin-*`) may be missing from the listing and are resolved directly.
243struct LocalTrackingRefs {
244    prefixes: Vec<String>,
245    names: HashSet<String>,
246}
247
248impl LocalTrackingRefs {
249    fn load(repo: &Repository, refspecs: &FetchRefspecs) -> Result<Self> {
250        let mut prefixes: Vec<String> = refspecs
251            .positive
252            .iter()
253            .filter_map(|item| item.dst.as_deref())
254            .filter_map(|dst| dst.find('*').map(|star| &dst[..star]))
255            .filter(|prefix| prefix.ends_with('/'))
256            .map(str::to_owned)
257            .collect();
258        prefixes.sort();
259        prefixes.dedup();
260        let mut names = HashSet::new();
261        for prefix in &prefixes {
262            names.extend(
263                refs::list_refs_in(repo, prefix)?
264                    .into_iter()
265                    .map(|(name, _)| name),
266            );
267        }
268        Ok(Self { prefixes, names })
269    }
270
271    /// Whether `tracking` resolves locally, consulting the ref store only for refs outside
272    /// the listed prefixes.
273    fn resolves(&self, repo: &Repository, tracking: &str) -> bool {
274        if self
275            .prefixes
276            .iter()
277            .any(|p| tracking.starts_with(p.as_str()))
278        {
279            return self.names.contains(tracking);
280        }
281        refs::resolve_ref_in(repo, tracking).is_ok()
282    }
283}
284
285fn remote_head_is_tracked(
286    repo: &Repository,
287    local: &LocalTrackingRefs,
288    config: &ConfigSet,
289    remote_name: &str,
290    head_short: &str,
291) -> bool {
292    let remote_ref = format!("refs/heads/{head_short}");
293    remote_tracking_ref_for_remote_ref(config, remote_name, &remote_ref)
294        .is_some_and(|tracking| local.resolves(repo, &tracking))
295}
296
297fn remote_head_branch_names(
298    refspecs: &FetchRefspecs,
299    remote_refs: &[RemoteRef],
300) -> BTreeSet<String> {
301    let mut names = BTreeSet::new();
302    for entry in remote_refs {
303        if !entry.name.starts_with("refs/heads/") {
304            continue;
305        }
306        if ref_excluded(&entry.name, &refspecs.negative) {
307            continue;
308        }
309        if match_positive(&entry.name, &refspecs.positive).is_some() {
310            if let Some(short) = entry.name.strip_prefix("refs/heads/") {
311                names.insert(short.to_owned());
312            }
313        }
314    }
315    names
316}
317
318fn branch_states_from_query(
319    repo: &Repository,
320    config: &ConfigSet,
321    remote_name: &str,
322    refspecs: &FetchRefspecs,
323    advertised: &[RemoteRef],
324) -> Result<Vec<RemoteBranchState>> {
325    let remote_heads: BTreeSet<String> = remote_head_branch_names(refspecs, advertised);
326
327    let remote_pairs: Vec<(String, ObjectId)> =
328        advertised.iter().map(|r| (r.name.clone(), r.oid)).collect();
329    let stale_full = tracking_refs_gone_in(repo, &refspecs.positive, &remote_pairs)?;
330    let stale_names: BTreeSet<String> = stale_full
331        .into_iter()
332        .filter_map(|(full, _)| head_short_name_for_tracking_ref(refspecs, remote_name, &full))
333        .collect();
334
335    let local = LocalTrackingRefs::load(repo, refspecs)?;
336    let mut by_name: BTreeMap<String, RemoteBranchStateKind> = BTreeMap::new();
337    for name in &remote_heads {
338        let state = if remote_head_is_tracked(repo, &local, config, remote_name, name) {
339            RemoteBranchStateKind::Tracked
340        } else {
341            RemoteBranchStateKind::New
342        };
343        by_name.insert(name.clone(), state);
344    }
345    for name in stale_names {
346        by_name.entry(name).or_insert(RemoteBranchStateKind::Stale);
347    }
348    Ok(by_name
349        .into_iter()
350        .map(|(name, state)| RemoteBranchState { name, state })
351        .collect())
352}
353
354fn head_short_name_for_tracking_ref(
355    refspecs: &FetchRefspecs,
356    remote_name: &str,
357    tracking_ref: &str,
358) -> Option<String> {
359    for item in &refspecs.positive {
360        let Some(dst) = item.dst.as_deref() else {
361            continue;
362        };
363        if let Some(star) = dst.find('*') {
364            let prefix = &dst[..star];
365            if tracking_ref.starts_with(prefix) {
366                let suffix = tracking_ref.strip_prefix(prefix)?;
367                if suffix.is_empty() || suffix.ends_with("/HEAD") {
368                    continue;
369                }
370                return Some(suffix.to_owned());
371            }
372        } else if dst == tracking_ref {
373            return item
374                .src
375                .as_deref()
376                .and_then(|s| s.strip_prefix("refs/heads/"))
377                .map(str::to_owned);
378        }
379    }
380    let prefix = format!("refs/remotes/{remote_name}/");
381    tracking_ref.strip_prefix(&prefix).map(str::to_owned)
382}
383
384fn apply_branch_limit(
385    mut branches: Vec<RemoteBranchState>,
386    limit: Option<usize>,
387) -> Vec<RemoteBranchState> {
388    if let Some(n) = limit {
389        branches.truncate(n);
390    }
391    branches
392}
393
394fn collect_pull_branches(config: &ConfigSet, remote_name: &str) -> Vec<RemotePullBranch> {
395    let mut by_branch: BTreeMap<String, RemotePullBranch> = BTreeMap::new();
396    for entry in config.entries() {
397        let Some(rest) = entry.key.strip_prefix("branch.") else {
398            continue;
399        };
400        let Some((branch, key)) = rest.rsplit_once('.') else {
401            continue;
402        };
403        if key != "remote" || entry.value.as_deref() != Some(remote_name) {
404            continue;
405        }
406        let merge = config
407            .get(&format!("branch.{branch}.merge"))
408            .unwrap_or_default();
409        by_branch.insert(
410            branch.to_owned(),
411            RemotePullBranch {
412                local: branch.to_owned(),
413                merges: merge,
414            },
415        );
416    }
417    by_branch.into_values().collect()
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::config::{ConfigFile, ConfigScope, ConfigSet};
424    use crate::repo::{InitOptions, Repository};
425
426    fn config_set(text: &str) -> ConfigSet {
427        let file = ConfigFile::parse(std::path::Path::new("cfg"), text, ConfigScope::Local)
428            .expect("parse");
429        let mut set = ConfigSet::new();
430        set.merge(&file);
431        set
432    }
433
434    #[test]
435    fn collect_pull_branches_reads_branch_config() {
436        let set = config_set(
437            "[remote \"origin\"]\n\turl = /tmp/r.git\n[branch \"main\"]\n\tremote = origin\n\tmerge = refs/heads/main\n[branch \"dev\"]\n\tremote = upstream\n\tmerge = refs/heads/dev\n[branch \"feature\"]\n\tremote = origin\n\tmerge = refs/heads/feature\n",
438        );
439        let pull = collect_pull_branches(&set, "origin");
440        assert_eq!(pull.len(), 2);
441        assert!(pull
442            .iter()
443            .any(|p| p.local == "main" && p.merges == "refs/heads/main"));
444        assert!(pull
445            .iter()
446            .any(|p| p.local == "feature" && p.merges == "refs/heads/feature"));
447    }
448
449    #[test]
450    fn apply_branch_limit_truncates() {
451        let branches = vec![
452            RemoteBranchState {
453                name: "a".into(),
454                state: RemoteBranchStateKind::Tracked,
455            },
456            RemoteBranchState {
457                name: "b".into(),
458                state: RemoteBranchStateKind::New,
459            },
460        ];
461        let limited = apply_branch_limit(branches, Some(1));
462        assert_eq!(limited.len(), 1);
463        assert_eq!(limited[0].name, "a");
464    }
465
466    #[test]
467    fn inspect_unknown_remote_errors() {
468        let dir = tempfile::tempdir().expect("tempdir");
469        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
470        let err = inspect_remote(&repo, "missing", &InspectOptions::default(), None)
471            .expect_err("unknown");
472        assert!(matches!(err, RemoteError::UnknownRemote(name) if name == "missing"));
473    }
474}